1
0

Add the service-to-service module

This commit is contained in:
2026-08-28 10:02:47 +05:30
parent cad813e1ae
commit 0fceb2cd4e
37 changed files with 2566 additions and 4 deletions

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Generate a throwaway CA, a server certificate for the downstream service, and two client
# certificates - one signed by that CA and one signed by a different CA. Into target/, so
# nothing here is committed and nothing here should ever be trusted.
#
# ./scripts/certs.sh
set -eu
cd "$(dirname "$0")/.."
D=target/certs
rm -rf "$D" && mkdir -p "$D"
cd "$D"
gen_ca() { # gen_ca <name> <cn>
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$1-ca.key" -out "$1-ca.crt" -subj "/CN=$2" 2>/dev/null
}
sign() { # sign <ca> <name> <subject> [extfile-content]
openssl req -newkey rsa:2048 -nodes -keyout "$2.key" -out "$2.csr" -subj "$3" 2>/dev/null
if [ -n "${4:-}" ]; then printf '%s\n' "$4" > "$2.ext"; else : > "$2.ext"; fi
openssl x509 -req -in "$2.csr" -CA "$1-ca.crt" -CAkey "$1-ca.key" -CAcreateserial \
-out "$2.crt" -days 3650 -extfile "$2.ext" 2>/dev/null
}
gen_ca internal "Internal Mesh CA"
gen_ca other "Some Other CA"
sign internal server "/CN=localhost" "subjectAltName=DNS:localhost,IP:127.0.0.1"
sign internal edge "/CN=edge-service/OU=payments"
sign other rogue "/CN=edge-service/OU=payments"
echo "wrote:"
ls -1 *.crt *.key | sed 's/^/ target\/certs\//'
echo
echo "Note that rogue.crt carries the SAME subject as edge.crt. Identity in mTLS is not the"
echo "subject; it is the subject plus the fact that a trusted CA vouched for it."

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Print the header and payload of a JWT without verifying it. For reading, not for trusting.
#
# ./scripts/claims.sh "$TOKEN"
set -eu
python3 - "$1" <<'PY'
import base64, json, sys
token = sys.argv[1]
for part in token.split('.')[:2]:
padded = part + '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(padded)), indent=2, sort_keys=True))
PY

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
# hand-written; if a claim in the article disagrees with a file here, the file is right.
#
# ./scripts/run-all.sh
#
# The whole module is started twice - once loose, once strict - plus a separate mTLS process,
# because the difference between those runs IS the content.
set -eu
cd "$(dirname "$0")/.."
OUT=docs/output
mkdir -p "$OUT"
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/"(exp|iat|nbf)": [0-9]+/"\1": <epoch>/g' \
-e 's/"(jti|kid)": "[0-9a-f-]+"/"\1": "<uuid>"/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/issuedAt=[^]]*\]/issuedAt=<timestamp>]/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
claims() { ./scripts/claims.sh "$1"; }
json() { python3 -m json.tool 2>/dev/null || cat; }
cc_token() { # cc_token <client> <secret>
curl -s -u "$1:$2" -X POST http://127.0.0.1:9000/oauth2/token \
-d grant_type=client_credentials -d scope=orders.read \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))'
}
########################################################################################
# LOOSE RUN
########################################################################################
./scripts/run.sh > /dev/null 2>&1
USER_TOKEN=$(./scripts/user-token.sh)
{
hdr "docs/output/01-user-token.txt
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
scripts/user-token.sh, then scripts/claims.sh"
claims "$USER_TOKEN"
echo
echo "# sub is the human. scope is what the human consented to. aud names the service the"
echo "# token was minted for - chapter 4 is about whether anybody looks at it."
} | scrub > "$OUT/01-user-token.txt"
{
hdr "docs/output/02-five-strategies.txt
The same request into the edge service, five ways of getting a token for the hop to
downstream. Read the sub and scope of each downstream response.
GET /edge/{naive,relay,client-credentials,exchange,relay-async}"
for endpoint in naive relay client-credentials exchange relay-async; do
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8081/edge/$endpoint"
curl -s -H "Authorization: Bearer $USER_TOKEN" "http://127.0.0.1:8081/edge/$endpoint" | json
echo
done
echo "# naive - 401. The control."
echo "# relay - sub: alice, scope: [orders.write, orders.read]. The user's own"
echo "# token, unchanged, including scopes downstream did not need."
echo "# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and"
echo "# the user has disappeared from downstream's audit log."
echo "# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is"
echo "# for and it is the one nobody reaches for."
echo "# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is"
echo "# a ThreadLocal, and the call was made on a different thread."
} | scrub > "$OUT/02-five-strategies.txt"
{
hdr "docs/output/03-audience-ignored.txt
A token minted for a DIFFERENT service, presented to the downstream service.
Default validators."
WRONG=$(cc_token reporting-service reporting-secret)
echo "# the token reporting-service was issued:"
claims "$WRONG"
echo
echo "\$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders"
curl -s -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders | json
echo
echo "# HTTP 200. The aud claim says reporting-api. The service is downstream-api."
echo "# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three"
echo "# validators - JwtTypeValidator, JwtTimestampValidator and"
echo "# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding."
echo "# No issuer. No audience. Read back by reflection in ValidatorContractTests."
} | scrub > "$OUT/03-audience-ignored.txt"
{
hdr "docs/output/04-gateway-token-relay.txt
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
GET /edge/relay through the gateway on 8080."
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/edge/relay"
curl -s -i -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay \
| sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):'
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay | json
echo
echo "\$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all"
curl -s -o /dev/null -w 'status %{http_code}\n' http://127.0.0.1:8080/edge/relay
echo
echo "# and the identical route with the TokenRelay filter REMOVED:"
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/norelay/x"
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/norelay/x | json
echo
echo "# TokenRelay relays the access token of the currently authenticated USER - the one"
echo "# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no"
echo "# authorized client to read a token from, and the filter contributes nothing. What"
echo "# reaches the edge service is whatever Authorization header the caller sent, because"
echo "# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'."
} | scrub > "$OUT/04-gateway-token-relay.txt"
########################################################################################
# STRICT RUN
########################################################################################
STRICT=true ./scripts/run.sh > /dev/null 2>&1
{
hdr "docs/output/05-strict-validation.txt
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
STRICT=true ./scripts/run.sh"
WRONG=$(cc_token reporting-service reporting-secret)
RIGHT=$(cc_token edge-service edge-secret)
echo "# the wrong-audience token that was accepted in 03:"
curl -s -i -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# a token minted for this service:"
curl -s -H "Authorization: Bearer $RIGHT" http://127.0.0.1:8082/orders | json
echo
echo "# and the edge service, which was NOT updated - it still uses Boot's"
echo "# auto-configured decoder:"
STRICT_USER=$(./scripts/user-token.sh)
curl -s -i -H "Authorization: Bearer $STRICT_USER" http://127.0.0.1:8081/edge/relay \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# Turning on RFC 9068 at the authorization server broke every resource server that"
echo "# still has NimbusJwtDecoder's default JOSE type verifier, and the error message"
echo "# mentions neither RFC 9068 nor the authorization server."
} | scrub > "$OUT/05-strict-validation.txt"
./scripts/stop.sh
########################################################################################
# mTLS
########################################################################################
./scripts/certs.sh > /dev/null
CP="target/classes:$(cat target/cp.txt)"
setsid nohup java -Xmx160m -cp "$CP" com.ankurm.s2s.mtls.MtlsApplication \
> /tmp/s2s-Mtls.log 2>&1 < /dev/null &
for _ in $(seq 1 60); do
curl -s -o /dev/null -m 2 --cacert target/certs/internal-ca.crt \
--cert target/certs/edge.crt --key target/certs/edge.key \
https://localhost:8443/mtls/whoami && break
sleep 1
done
{
hdr "docs/output/06-mtls.txt
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
different issuers."
echo "\$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer"
openssl x509 -in target/certs/edge.crt -noout -subject -issuer
echo "\$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer"
openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
echo
echo "\$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key https://localhost:8443/mtls/whoami | json
echo
echo "\$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/rogue.crt \
--key target/certs/rogue.key https://localhost:8443/mtls/whoami \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint"
curl -sk https://localhost:8443/mtls/trusted-header \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \\"
echo " https://localhost:8443/mtls/trusted-header"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
https://localhost:8443/mtls/trusted-header
echo
echo
echo "# Three things worth reading twice."
echo "# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake"
echo "# is rejected; the application never sees a request and logs nothing at INFO."
echo "# 2. So does the permitAll() endpoint. client-auth=need is a property of the"
echo "# CONNECTOR, not of a path. You cannot expose a public endpoint on that port."
echo "# 3. The last call is what a mesh deployment usually looks like from inside the"
echo "# application: an identity taken from a header, verified by nothing."
} | scrub > "$OUT/06-mtls.txt"
for pid in $(ps -eo pid,ppid,comm,args | awk '$3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\.mtls/ {print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
########################################################################################
# TESTS
########################################################################################
{
hdr "docs/output/07-tests.txt
mvn -B test"
mvn -B test 2>&1 | grep -E 'Tests run|ERROR|BUILD' | head -30
} | scrub > "$OUT/07-tests.txt"
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Start all four processes and wait until each answers.
#
# ./scripts/run.sh
# STRICT=true ./scripts/run.sh # downstream validates issuer and audience
# RS_LOG_LEVEL=DEBUG ./scripts/run.sh # resource-server decisions at DEBUG
#
# | Process | Port | Main class |
# |-------------|------|---------------------------|
# | authserver | 9000 | AuthServerApplication |
# | gateway | 8080 | GatewayApplication |
# | edge | 8081 | EdgeApplication |
# | downstream | 8082 | DownstreamApplication |
#
# These are launched with plain `java`, not `spring-boot:run`. Four Maven JVMs each forking an
# application JVM is eight processes, and on a small machine that is how you meet the OOM
# killer rather than the demo. `mvn dependency:build-classpath` once, then `java -cp` four
# times, is two hundred megabytes of heap instead of two gigabytes.
set -eu
cd "$(dirname "$0")/.."
./scripts/stop.sh
mvn -B -q compile
if [ ! -f target/cp.txt ]; then
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
fi
CP="target/classes:$(cat target/cp.txt)"
start() { # start <main-class> <port> <health-path> <extra-jvm-args...>
local main="$1" port="$2" path="$3"
shift 3
setsid nohup java -Xmx192m -XX:TieredStopAtLevel=1 "$@" ${JVM_ARGS:-} \
-cp "$CP" "com.ankurm.s2s.$main" \
> "/tmp/s2s-${main##*.}.log" 2>&1 < /dev/null &
for _ in $(seq 1 60); do
if curl -s -o /dev/null "http://127.0.0.1:$port$path" 2>/dev/null; then
echo " ${main##*.} up on $port"
return 0
fi
sleep 1
done
echo "${main##*.} did not start; see /tmp/s2s-${main##*.}.log" >&2
tail -20 "/tmp/s2s-${main##*.}.log" >&2
return 1
}
# STRICT=true turns on RFC 9068 end to end: the authorization server types its access tokens
# `at+jwt` and adds a client_id claim, and the downstream service validates issuer, audience
# and the required-claim set instead of only signature and expiry.
STRICT_ARGS=()
AS_ARGS=()
if [ "${STRICT:-false}" = "true" ]; then
STRICT_ARGS=(-DSTRICT=true)
AS_ARGS=(-DAT_JWT=true)
fi
# The authorization server must be first and must be READY before the others: `edge` is an
# OAuth2 client, and a client whose provider is configured with issuer-uri fetches the
# discovery document during context refresh. See docs/01-the-four-processes.md.
start authserver.AuthServerApplication 9000 /oauth2/jwks "${AS_ARGS[@]}"
start downstream.DownstreamApplication 8082 /orders "${STRICT_ARGS[@]}"
start edge.EdgeApplication 8081 /edge/naive
start gateway.GatewayApplication 8080 /edge/naive
echo "all four up"

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Stop every process in the module.
#
# Two rules, both learned the hard way while building this repository:
#
# 1. Match the MAIN CLASS, never 'spring-boot' or 'java'. `pkill -f spring-boot` also matches
# the shell command line that launched the application, so it kills your own shell.
#
# 2. Restrict the match to processes that are actually a JVM, and exclude this script and its
# parent. A bracketed pattern like '[A]uthServerApplication' stops the pattern matching the
# grep itself - but it does NOT stop it matching an ancestor shell whose command line
# happens to contain that string, which is exactly what happens when you paste a here-doc
# containing the class name into a terminal and then run this script from it. The victim
# process dies with exit 137 and no output, which is a memorable afternoon.
set -eu
SELF=$$
PARENT=$PPID
ps -eo pid,ppid,comm,args | awk -v self="$SELF" -v parent="$PARENT" '
$1 != self && $1 != parent && $3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\./ { print $1 }
' | while read -r pid; do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Drive a complete authorization_code + PKCE flow with curl and print the access token.
#
# TOKEN=$(./scripts/user-token.sh)
#
# There is no browser here and none is needed: the "browser flow" is four HTTP requests and a
# cookie jar. Doing it by hand once is the fastest way to understand what your SPA's OIDC
# library is actually doing, and it makes every transcript in docs/output/ reproducible.
set -eu
AS=http://127.0.0.1:9000
JAR=$(mktemp)
trap 'rm -f "$JAR"' EXIT
# 1. PKCE: a random verifier, and its base64url-encoded SHA-256 as the challenge.
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=')
AUTHORIZE="$AS/oauth2/authorize?response_type=code&client_id=spa-client\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=orders.read%20orders.write\
&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 2. Ask for the code. Unauthenticated, so this saves the request and redirects to /login.
curl -s -o /dev/null -c "$JAR" -b "$JAR" "$AUTHORIZE"
# 3. Log in. The login page is CSRF-protected, so read the token out of the form.
CSRF=$(curl -s -c "$JAR" -b "$JAR" "$AS/login" \
| grep -oiE 'name="_csrf"[^>]*value="[^"]*"' | head -1 | sed 's/.*value="//; s/"//')
curl -s -o /dev/null -c "$JAR" -b "$JAR" -X POST "$AS/login" \
-d "username=alice" -d "password=password" -d "_csrf=$CSRF"
# 4. Follow the saved request. Now authenticated, so this redirects to the redirect_uri
# carrying ?code=... We never let curl follow it; we just read the Location header.
CODE=$(curl -s -o /dev/null -D- -c "$JAR" -b "$JAR" "$AUTHORIZE" \
| grep -i '^location:' | sed 's/.*code=//; s/[&\r].*//')
if [ -z "$CODE" ]; then
echo "no authorization code was issued - is the auth server up?" >&2
exit 1
fi
# 5. Redeem it. A public client, so no client secret: the code_verifier is the proof.
curl -s -X POST "$AS/oauth2/token" \
-d grant_type=authorization_code \
-d "code=$CODE" \
-d "redirect_uri=http://127.0.0.1:8080/authorized" \
-d client_id=spa-client \
-d "code_verifier=$VERIFIER" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'