OIDC Discovery & JWKS
What this is
The read-only discovery documents an OAuth/OIDC client fetches before it ever calls us — to learn where our endpoints are and how to verify our tokens. Three static documents:
Endpoint |
Purpose |
Standard |
|---|---|---|
|
OIDC provider metadata |
OpenID Discovery 1.0 |
|
OAuth 2.0 AS metadata |
RFC 8414 |
|
Public signing keys (JWK Set) |
RFC 7517 |
Why it is needed
A client must discover endpoint URLs, supported grants/scopes, and the keys to
verify issued tokens. OIDC clients read openid-configuration; plain OAuth 2.0
clients read oauth-authorization-server (same data, RFC 8414 field names). Both
point at jwks.json for the public keys.
Pre-requisites
One RSA signing key exists as a KMS asymmetric key (
RSA_2048,SIGN_VERIFY); the private half never leaves KMS. The public half is published in JWKS. (ASecureStringPEM in SSM Parameter Store remains a local/dev fallback, used only whenESPUSER_KMS_SIGNING_KEY_ARNis unset.)The deployment
issuer(public base URL) is known.
Signing key (KMS)
Where it lives. The signing key is a KMS asymmetric RSA key. The token minter signs by sending the token’s SHA-256 digest to
kms:Sign(SigningAlgorithm=RSASSA_PKCS1_V1_5_SHA_256,MessageType=DIGEST), which is exactly RS256 — so the JWS the minter assembles verifies against a standard RSA public key with no KMS on the verify path. The private key material is never exported; a lambda holds onlykms:Sign(mint) orkms:GetPublicKey(publish), never the key bytes.kid= RFC 7638 JWK thumbprint of the public key. The minter and the JWKS publisher both derive it from the same public key, so they agree without sharing state, and it changes iff the key changes — which is precisely when a rotation wants a newkid.Rotation. A new KMS key yields a new thumbprint
kid; publish an overlap JWKS containing both keys for at leastmax(access-token TTL, JWKS S3 cache = 86400s)before retiring the old key. Verifiers cache the JWKS without refetching on an unknownkid, so a key addition needs a consumer refresh (a redeploy does this); a key removal is only safe after the overlap.Provider parity.
jwtutils.Mintersigns through acrypto.Signer, so the KMS signer (production) and a local*rsa.PrivateKey(tests, dev fallback) share one code path and produce identical RS256 output.
Key Rules
RS256 only. Sole signing alg;
alg: nonerejected (RFC 8725).issuer= deployment base URL. Every URL in all three docs is built from it;jwks_uri=<issuer>/.well-known/jwks.json.Static, public, cacheable. No secrets — JWKS publishes only public JWKs. Generated once and served from S3; no Lambda on the read path.
Issuer is fixed once chosen. It is signed into every token, so changing it is a migration, not a toggle.
Token usage: id token vs access token
The two issued JWTs serve distinct purposes, per OpenID Connect Core 1.0 token semantics:
The id token identifies the user. It is the identification artifact, consumed at the userinfo endpoint (auth-flows.md); it is not an authorization credential for a resource server.
The access token authorizes actions. It is the RFC 6749 / RFC 6750 bearer credential a relying party presents to act on the user’s behalf — for example, to obtain short-lived AWS/IoT credentials. Keeping the two separate avoids handing the id token to a resource surface it was not issued for.
The access token issued here is what a relying party exchanges for AWS/IoT credentials via Cognito identity-pool OIDC federation; that mechanism (the identity pool, the device-users role, STS
AssumeRoleWithWebIdentity) is owned by the ESP RainMaker Neo side — see user_auth.md.
Design
The three documents are immutable and identical for every caller, so they are generated once and served as static S3 objects — not computed per request.
Tier 1 (default): public S3 objects over HTTPS;
issuer= the S3 URL.Tier 2 (custom domain): CloudFront (ACM cert) over a private S3 origin (OAC);
issuer= the custom domain. Added only when a custom domain is attached.
A publisher (deploy hook; later, key rotation) builds the objects from the issuer + the key’s public half and writes them to S3:
sequenceDiagram
participant Pub as "Publisher (deploy hook)"
participant KMS as "KMS (asymmetric RSA key)"
participant S3 as "S3 bucket"
Pub->>KMS: GetPublicKey (public half only)
Pub->>Pub: public key -> JWK (kid = RFC 7638 thumbprint) — issuer -> metadata
Pub->>S3: PutObject .well-known/openid-configuration
Pub->>S3: PutObject .well-known/oauth-authorization-server
Pub->>S3: PutObject .well-known/jwks.json
The build logic (issuer → metadata struct, public key → JWK Set struct) lives in shared utils so the publisher and any future request-time handler share it.
APIs
OIDC Provider Metadata
External Flow
An OIDC client fetches
<issuer>/.well-known/openid-configuration, then caches it.
Internal Flow
API: GET /.well-known/openid-configuration
Process:
Serve the static object built from
issuer.Content-Type: application/json,Cache-Control: public, max-age=86400.
Response (fields REQUIRED by OpenID Discovery 1.0):
field |
example |
notes |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
JWKS — public signing keys
External Flow
The client fetches
jwks_uri, then verifies each token by matching the JWT headerkidto a key. On an unknownkid(rotation) it re-fetches once.
Internal Flow
API: GET /.well-known/jwks.json
Process:
Serve the static JWK Set — public half of every trusted signing key.
Content-Type: application/json,Cache-Control: public, max-age=86400.
Response (per-key fields required by RFC 7517/7518 for an RSA verify key —
kty, kid, use, alg, n, e):
field |
example |
notes |
|---|---|---|
|
|
v1 publishes one active key. Rotation (publish-next → flip-active → retire) is deferred to the token-issuing slice.
Rotation overlap must be ≥ the JWKS cache lifetime. JWKS is cached for 1 day (
max-age=86400), so a verifier may hold a stale key set for up to a day. When rotation lands, publish the new key before it signs and keep the old key in JWKS for ≥ 1 day after the new key becomes active — otherwise tokens signed by the new key fail verification against caches that predate it.