Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.
Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:
- OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
in 7.0, and both configuration classes moved into spring-security-config
- ClientSettings.requireProofKey flipped from false to true, on the authorization server
(1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
- requireProofKey(false) does not make PKCE optional for a public client; the code
verifier is that client's only authentication at the token endpoint
- MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called
Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
92 lines
3.2 KiB
Bash
Executable File
92 lines
3.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Shared helpers. Sourced by every demo script.
|
|
AS=http://localhost:9000
|
|
RS=http://localhost:8090
|
|
|
|
# Kill by main class, never by a pattern that could match this script's own command line.
|
|
# `pkill -f spring-boot` matches the shell running it and takes the shell with it.
|
|
kill_app() {
|
|
local mainclass="$1" port="${2:-}"
|
|
for p in $(ps -eo pid,cmd | grep "[${mainclass:0:1}]${mainclass:1}" | awk '{print $1}'); do
|
|
kill -9 "$p" 2>/dev/null || true
|
|
done
|
|
# Then wait for the port to actually close. `ss -lptn` often reports the socket with no
|
|
# PID, so a port-based kill can silently do nothing while the old process keeps serving -
|
|
# which looks exactly like your config change having had no effect. Waiting for the
|
|
# listener to disappear is the only reliable signal that the restart is real.
|
|
if [ -n "$port" ]; then
|
|
for _ in $(seq 1 30); do
|
|
curl -s -o /dev/null --max-time 1 "http://localhost:$port/" || return 0
|
|
sleep 1
|
|
done
|
|
echo "WARNING: something is still listening on :$port after kill_app $mainclass" >&2
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
}
|
|
|
|
wait_for() {
|
|
local url="$1" tries="${2:-90}"
|
|
for _ in $(seq 1 "$tries"); do
|
|
if curl -s -o /dev/null --max-time 2 "$url"; then return 0; fi
|
|
sleep 1
|
|
done
|
|
echo "timed out waiting for $url - check the app log" >&2
|
|
return 1
|
|
}
|
|
|
|
hr() { printf '%s\n' "------------------------------------------------------------------"; }
|
|
|
|
section() { echo; hr; echo "== $*"; hr; }
|
|
|
|
# Decode a JWS payload without verifying it. Debug only - never do this to decide anything.
|
|
jwt_payload() {
|
|
python3 - "$1" <<'PY'
|
|
import base64, json, sys
|
|
part = sys.argv[1].split('.')[1]
|
|
part += '=' * (-len(part) % 4)
|
|
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
|
|
PY
|
|
}
|
|
|
|
jwt_header() {
|
|
python3 - "$1" <<'PY'
|
|
import base64, json, sys
|
|
part = sys.argv[1].split('.')[0]
|
|
part += '=' * (-len(part) % 4)
|
|
print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
|
|
PY
|
|
}
|
|
|
|
# Pull a hidden input's value out of a page. Attribute order is not fixed - Spring
|
|
# Security's default login page renders name before value, Thymeleaf renders value before
|
|
# name - so a naive grep for name="x" value="y" works on one and silently returns empty on
|
|
# the other. Empty CSRF token, HTTP 403, and an hour lost.
|
|
form_value() {
|
|
python3 - "$1" "$2" <<'PY'
|
|
import re, sys
|
|
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
|
|
want = sys.argv[2]
|
|
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
|
|
attrs = dict((m.group(1).lower(), m.group(2))
|
|
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
|
|
if attrs.get('name') == want:
|
|
print(attrs.get('value', ''))
|
|
break
|
|
PY
|
|
}
|
|
|
|
# Every value of a repeated input (the scope checkboxes on the consent page).
|
|
form_values() {
|
|
python3 - "$1" "$2" <<'PY'
|
|
import re, sys
|
|
html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
|
|
want = sys.argv[2]
|
|
for tag in re.findall(r'<input\b[^>]*>', html, re.I):
|
|
attrs = dict((m.group(1).lower(), m.group(2))
|
|
for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
|
|
if attrs.get('name') == want and 'disabled' not in tag.lower():
|
|
print(attrs.get('value', ''))
|
|
PY
|
|
}
|