start from OAuth 2.0
Let’s start directly with OAuth 2.0.
OAuth 1 is long outdated. RFC 5849
So let’s go straight to OAuth 2.0. RFC6749
what is OAuth 2.0
Before we start, keep these points in mind:
- OAuth is an authorization framework.
- The OAuth protocol is an authorization protocol.
- OAuth 2.0 is an authorization protocol.
- The OAuth 2.0 framework is an authorization framework.
(Mm-hm.)
The OAuth 2.0 framework lets third-party applications access HTTP services with limited permissions. It can establish an approval interaction between the resource owner and the HTTP service, allowing a third-party application to access the service on the owner’s behalf, or grant permissions to the third-party application so it can access the service on its own behalf.
Authorized Access
The OAuth protocol was designed to:
Let end users use OAuth to delegate some of their permissions on protected resources to a client application.
Protected resources rely on an authorization server to issue clients a dedicated security credential: the OAuth access token.
Two basic elements: Getting a token Using a token
Core: Delegated Authorization
flowchart LR
RO[Resource owner]
Client[Client]
RS[Protected resource]
RO -->|
Goal:
Let the client access protected resources on behalf of the resource owner
| Client
Client -->|Access protected resources| RS
So how do we actually grant this authorization?
There is nothing another layer of middleware cannot solve. If there is, add one more layer.
sequenceDiagram
participant RO as Resource owner
participant Client as Client
participant AS as Authorization server
participant RS as Protected resource
Client->>RO: Client requests authorization
RO-->>AS: Resource owner approves authorization
rect rgba(255, 0, 0, 0.42)
Client->>AS: Client sends authorization grant
AS-->>Client: Authorization server sends access token
end
Client->>RS: Client sends access token
RS-->>Client: Protected resource returns resources
It provides a way for a client to ask a user to delegate some of their permissions to it, without exposing the resource owner’s credentials to the client along the way.
Of course, this is only a general overview of how OAuth works. OAuth has several ways to obtain tokens.
Roles and Endpoints
The diagrams above already introduced four roles: the resource owner, client, authorization server, and protected resource.
Communication between these roles goes through three clearly defined HTTP endpoints.
- Authorization Endpoint: The client redirects the resource owner to this endpoint. It only supports GET.
- Token Endpoint: The client exchanges an authorization grant for an access token.
- Redirection Endpoint(callback / redirect_uri)
OAuth divides clients into two types. Client type
- Confidential Client
- Public Client
Grant Types
A grant type is a type of authorization grant.
Authorization Code Grant
- The client redirects the user to the Authorization Endpoint.
- The user logs in and approves authorization.
- The authorization server returns an authorization code to the client through the Redirection Endpoint.
- The client exchanges the authorization code for an access token through the Token Endpoint (on the AS).
Implicit Grant
A shortcut designed for purely frontend single-page applications in the early days.
It skips the code exchange at the Token Endpoint. The authorization server puts the access token directly in the fragment of the redirect URL (#access_token=xxx).
That saves one network request.
But
the token is directly exposed in the URL, browser history, and Referer headers, and there is no refresh token.
Resource Owner Password Credentials
sequenceDiagram
actor User as Resource owner
participant Client as Client
participant AS as Authorization server
User->>Client: Enter username + password
Client->>AS: POST /token (grant_type=password, username, password)
AS-->>Client: access token
Whoa, why are you handing over the user’s password directly? That’s not how it works in my OAuth dating sim!
OAuth exists precisely to avoid this, yet ROPC makes it explicit.
The only reasonably sensible case is an organization’s own app authenticating directly without going through the browser.
Client Credentials Grant
only for Credential Client
The previous flows all assume there is a user.
But in machine-to-machine (M2M) scenarios, there is no user at all.
What if one backend service needs to access another backend service’s API?
Resource owners? We don’t need those.
The client can go straight to the Token Endpoint with client_id + client_secret to get a token:
sequenceDiagram
participant Client as Client (Service A)
participant AS as Authorization server
participant RS as Resource server (Service B)
Client->>AS: POST /token (grant_type=client_credentials)
AS-->>Client: access token
Client->>RS: Access API with token
RS-->>Client: Return resources
Refresh Token and scope
Access tokens are short-lived, but expiration does not mean the user has to log in again.
The client can use a refresh token to get a new access token directly from the Token Endpoint.
scope: When first requesting authorization, the client can declare the permission scopes it needs (e.g. scope=read:email write:posts). The resource owner only approves that scope, and the permissions of the resulting access token are then fixed.
state
Before redirecting to the Authorization Endpoint, the client generates a random value for state, then checks state after the authorization server finishes.
CSRF protection.
Pros and Cons
OAuth 2.0 is very good at capturing a user’s delegation decision and transmitting it over a network. It allows multiple parties to participate in security decisions, especially the end user at runtime. It is a protocol with many moving parts, but in many ways it is simpler and more secure than other approaches.
A single authorization server can easily protect multiple resource servers, and many different kinds of clients may want to access a particular API. One authorization server can even have several levels of client trust. This architecture moves as much complexity as possible from the client to the server.
OAuth tokens provide a mechanism slightly more complex than passwords, so they are much more secure when used correctly. <- Figuring out what ‘used correctly’ means is an important development problem.
Extensibility and modularity are among OAuth 2.0’s greatest strengths, making the protocol suitable for many environments. Yet this very flexibility creates fundamental compatibility problems between implementations.
Some custom options can also be misused or used incorrectly, making an implementation insecure. Even implementing OAuth correctly according to the specification does not mean a system is secure in production.
OAuth 2.0 CANNOT
- OAuth does not define scenarios outside the
HTTPprotocol.
OAuth 2.0 with bearer tokens does not provide message signing, so it needs a transport mechanism such as TLS to protect information.
RFC7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth
Of course, there are now plenty of attempts to use it over non-TLS connections.
- OAuth is not an authentication protocol.
You’re right that you can build authentication with it, but it still isn’t one.
An OAuth transaction itself does not reveal information about the user. It may use authentication in several places (e.g. the resource owner and client software authenticate to the authorization server), but that embedded authentication does not turn OAuth into an authentication protocol.
- OAuth does not define a user-to-user authorization mechanism.
Even though it is fundamentally a protocol for a user to authorize software.
OAuth assumes the resource owner can control the client. OAuth alone cannot let a resource owner authorize another user. But the User Managed Access protocol can.
- OAuth does not define an authorization-processing mechanism.
OAuth provides a way to communicate that authorization has been delegated, but does not define what is authorized. It only communicates that the delegation happened.
The service API defines permissions for operations using OAuth components such as scopes and tokens.
- OAuth does not define a token format.
The OAuth protocol declares token contents completely opaque to the client, but the authorization server and protected resource still need to understand them.
This need for interoperability gave rise to the JSON Web Token format and the token introspection format.
OAuth 2.1
Oauth 2.1 / draft-ietf-oauth-v2-1
2.1 is still an IETF draft, not a final RFC.
OAuth 2.1 combines the OAuth 2.0 core protocol, Bearer Token usage, and PKCE documents. It adds no new endpoints.
Much of it comes directly from RFC 9700 (OAuth 2.0 Security Best Current Practice
Subtraction
That’s right, 2.1 < 2.0.
Core: Narrow down the choices.
What changed in Oauth 2.1
- The authorization code flow must use PKCE, and PKCE’s
plainmode is removed entirely.
PKCE originally protected mobile native apps against intercepted authorization codes RFC 7636. 2.1 extends it to all clients, including confidential clients.
There are three additional parameters: code_verifier, code_challenge, and code_challenge_method.
sequenceDiagram
autonumber
participant Client
participant Attacker
participant AS as Authorization Server
Client->>Client: Generate code_verifier = "XYZ123"<br/>(plain mode: code_challenge = "XYZ123")
Client->>AS: Authorization Request (code_challenge=XYZ123, method=plain)
Note over Client,Attacker:wrap: ⚠️ Attacker sniffs request and steals code_verifier directly!
AS-->>Client: Return Authorization Code
Note over Client,Attacker:wrap: Attacker intercepts Authorization Code
Attacker->>AS: POST /token (code + code_verifier=XYZ123)
AS-->>Attacker: ⚠️ 200 OK (PKCE bypassed! Token issued to Attacker)
Intercepting the code is useless without the code_verifier. 🚫
- The implicit grant is removed entirely.
sequenceDiagram
autonumber
actor User
participant Browser
participant AS as Authorization Server
participant Attacker as Attacker / Malicious Script
User->>Browser: Click "Login with OAuth"
Browser->>AS: GET /authorize?response_type=token...
AS-->>Browser: 302 Redirect to https://app.com/#35;access_token=secret_token
Note over Browser: ⚠️ Token is exposed in URL Fragment & Browser History
Browser->>Browser: Execute page scripts / Click external link
Browser-->>Attacker: ⚠️ Leak token via Referer header, DOM, or XSS
Attacker->>Attacker: Full access using stolen Access Token
- ROPC (Resource Owner Password Credentials) is removed.
%% @style editorial
%% @title ROPC · The password passes through the client
sequenceDiagram
autonumber
participant User as User
participant Client as Client
participant AS as Authorization server
participant RS as Resource server
User->>Client: Submit username and password
Note over Client: Password exposed to client
Client->>AS: Request token using password
AS-->>Client: Return Access Token
Client->>RS: Request resources with token
RS-->>Client: Return protected resources
The problem with this flow: The user hands their original credentials to the client. The plaintext password may end up in client memory, logs, or third-party SDKs. Exchanging it directly for a token also bypasses MFA, Passkeys, SSO, and the authorization consent page.
The request details from the diagram are kept here so they don’t stretch the entire diagram horizontally:
POST /tokenContent-Type: application/x-www-form-urlencoded
grant_type=password&username=<username>&password=<password>
HTTP/1.1 200 OKAfter obtaining the Access Token, the client accesses resources through a request header:
GET /resourceAuthorization: Bearer <token>-
redirect_urimust match exactly, character for character. -
Bearer tokens may not be placed in the query string.
Putting a token in a URL means it will appear in browser history, server access logs, Referer headers, and even the logging systems of any layer along the forwarding path. This has been upgraded from a recommendation to a hard requirement: tokens must go in a header or the body.
sequenceDiagram
autonumber
participant Client
participant Proxy as CDN / Nginx Proxy
participant RS as Resource Server
participant Attacker
Client->>Proxy: GET /api/orders?access_token=secret_jwt_token
Note over Proxy: ⚠️ Token logged in plaintext to access.log & metrics
Proxy->>RS: Forward Request
RS-->>Client: 200 OK Response
Note over Attacker,Proxy: Attacker accesses log system (e.g. ELK, CloudWatch)
Attacker->>Proxy: Read Access Logs
Proxy-->>Attacker: ⚠️ Extract valid access_tokens from URLs
- Refresh tokens must use rotation or be sender-constrained.
A token must be replaced after every use (rotation), or strongly bound to a particular client (sender-constrained, e.g. DPoP RFC 9449 or mTLS).
Edge case: A user opens two tabs that trigger a refresh almost simultaneously. Revoking the entire family immediately would kick out a legitimate user too. In practice, a short grace period allows reuse of an old token shortly after rotation to count as harmless concurrency rather than a replay.
Sender-constraining is stronger: DPoP requires the client to sign a proof with its own private key on every request;
mTLS is heavier and requires client certificates.
Most scenarios do not need something this heavy. Rotation already covers most threat models.
sequenceDiagram
autonumber
participant Client
participant AS as Authorization Server
participant RS as Resource Server
participant Attacker
Note over Client: 1. Generate Key Pair (Public Key & Private Key)
Client->>AS: 2. POST /token + Public Key
Note over AS: Bind Token to Public Key<br/>(Injects 'cnf' claim with Public Key hash)
AS-->>Client: 3. Return Sender-Constrained Token
Note over Client: 4. Sign HTTP Request using Private Key (Create Proof)
Client->>RS: 5. GET /api/data<br/>• Authorization: DPoP/Bearer <token><br/>• Proof Header: <Signature from Private Key>
Note over RS: Verify Token validity AND<br/>Verify Signature matches Public Key in 'cnf'
RS-->>Client: 6. 200 OK (Return Protected Data)
Note over Attacker: ⚠️ Attacker steals the Access Token (e.g. via logs / XSS)
Attacker->>RS: 7. GET /api/data with stolen Token<br/>(Attacker does NOT have the Client's Private Key)
Note over RS: ❌ Verification Failed:<br/>Missing or Invalid Proof of Possession
RS-->>Attacker: 8. 401 Unauthorized (Attack Blocked!)
OAuth Endpoints
Although 2.1 itself adds no endpoints, that does not mean OAuth only has an Authorization Endpoint and a Token Endpoint.
There are also:
- Pushed Authorization Requests / PAR(RFC 9126)
- Token Revocation(RFC 7009
- Token Introspection(RFC 7662
- Device Authorization Grant(RFC 8628
- Dynamic Client Registration(RFC 7591
OpenID Connect (OIDC)
OAuth is not an authentication protocol.
OAuth only handles Authorization, not Authentication.
And so OpenID Connect appeared.
OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. —— OIDC Core 1.0
OIDC reuses OAuth’s authorization flows and endpoints, adding an ID Token to the Token Endpoint response, along with a UserInfo Endpoint.
ID Token vs Access Token
- Access Token: Native to OAuth, intended for the resource server, proves the client’s permissions, and has a format opaque to the client.
- ID Token: Part of OIDC, always a JWT, intended for the client, and proves the identity that logged in.
How to use OIDC
The client’s authorization request includes openid in scope (e.g. scope=openid profile email).
The authorization server then returns an additional id_token field at the Token Endpoint.
UserInfo Endpoint
The claims in an ID Token are a compact version.
For more complete user information, OIDC defines a UserInfo endpoint.
The client uses its access token to obtain information from this endpoint.
Discovery
OIDC providers (e.g. Google and Auth0) serve JSON at the fixed path /.well-known/openid-configuration, listing their Authorization Endpoint, Token Endpoint, UserInfo Endpoint, supported scopes, signing algorithms, and so on.
$ curl https://link.sast.fun/v2/.well-known/openid-configuration
{"authorization_endpoint":"https://link.sast.fun/v2/oauth/authorize","claim_types_supported":["normal"],"claims_parameter_supported":false,"claims_supported":["sub","iss","aud","exp","iat","nonce","name","picture","preferred_username","role","email","email_verified","updated_at"],"code_challenge_methods_supported":["S256"],"grant_types_supported":["authorization_code","refresh_token"],"id_token_signing_alg_values_supported":["EdDSA"],"issuer":"https://link.sast.fun/v2","jwks_uri":"https://link.sast.fun/v2/.well-known/jwks.json","request_parameter_supported":false,"request_uri_parameter_supported":false,"response_modes_supported":["query"],"response_types_supported":["code"],"revocation_endpoint":"https://link.sast.fun/v2/oauth/revoke","scopes_supported":["openid","profile","email","admin:read","admin:write","user:read","user:write"],"subject_types_supported":["public"],"token_endpoint":"https://link.sast.fun/v2/oauth/token","token_endpoint_auth_methods_supported":["none","client_secret_post"],"userinfo_endpoint":"https://link.sast.fun/v2/userinfo"}Standard Claims
OIDC defines a set of standard claims, spread across three sources:
- ID Token: Returned directly when authorization completes, containing the most basic identity assertions.
- UserInfo Endpoint: Requires a separate request and returns a more complete user profile.
- Scope control: Different scopes determine which claims are included.
Standard Claims Set
The standard claims defined by OIDC Core fall into several categories:
Required / core claims (in the ID Token):
sub: Subject Identifier, a unique user identifier that never changes within the same OP (OpenID Provider).iss: Issuer, the OP URL.aud: Audience, usually the client’sclient_id.exp: Expiration Time, the expiration timestamp.iat: Issued At, the issuance timestamp.
Claims associated with the profile scope:
name: Full name.family_name/given_name: Family name / given name.middle_name: Middle name.nickname: Nickname.preferred_username: Preferred username.profile: Profile page URL.picture: Avatar URL.website: Personal website.gender: Gender.birthdate: Date of birth (YYYY-MM-DD format).zoneinfo: Time zone (e.g.Asia/Shanghai).locale: Locale (e.g.zh-CN).updated_at: Timestamp of the last profile update.
Claims associated with the email scope:
email: Email address.email_verified: Whether the email address has been verified (boolean).
Claims associated with the address scope:
address: A JSON object containingformatted(full address),street_address,locality(city),region(province/state),postal_code, andcountry.
Claims associated with the phone scope:
phone_number: Phone number (E.164 format, e.g.+86 138...).phone_number_verified: Whether the phone number has been verified.
Optional claims
nonce: A random value the client sends in the authorization request. Prevents replay attacks. Note: (Replay attacks)[https://en.wikipedia.org/wiki/Replay_attack]auth_time: Timestamp of the user’s actual authentication.acr: Authentication Context Class Reference, the authentication context level (e.g.urn:mace:incommon:iap:silverindicates multifactor authentication).amr: Authentication Methods References, an array of authentication methods (e.g.["pwd", "otp"]means password + OTP).azp: Authorized Party, the ID of the client actually using the token (which may differ fromaudin multi-RP scenarios).
Custom Claims
Custom claims outside the standard have no namespace restrictions. Still, a little care wouldn’t hurt, right? (
- Avoid names that clash with standard claims.
- Use a URL as a namespace prefix to avoid collisions.
- Or use conventional short prefixes (e.g.
org_role,tenant_id).
How claims are split between ID Token and UserInfo
Not every claim appears in both places:
- ID Token: Space is limited, so it usually contains only the core claims (
sub,iss,aud,exp,iat, plusnonceand a few identity fields such asemail). - UserInfo: The complete version.
Trustworthiness of Claims
- Claims in the ID Token: Protected by a signature; clients can verify them offline for third parties or caching.
- Claims in UserInfo: Retrieved from the OP over HTTPS, without a signature.
Security-critical decisions must never rely solely on the snapshot of claims in a token. The backend must read the database again.
The values in a token are only for routing requests or frontend display.
JWT Structure and Verification
Since an ID Token is a JWT, we might as well cover this too.
The Three Parts of a JWT
A JWT consists of three Base64URL-encoded parts:
eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImxpbmstdjItYWN0aXZlIn0.eyJzdWIiOiIxMjM0NTYiLCJpc3MiOiJodHRwczovL2xpbmsuc2FzdC5mdW4vdjIiLCJhdWQiOiJjbGllbnRfaWQiLCJleHAiOjE3MDAwMDAwMDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJhYmMxMjMifQ.6F8kT9VnX3p5ZrQw2J4Ks8YbL1MvN3uP7tR5eX9Gh2Qm4Wp3Yt7Zl6Kj8Hn9Vm2The three parts are:
- Header (
eyJ...): Describes the signing algorithm and key ID. - Payload (
eyJ...): The actual claims. - Signature (
6F8...): A signature over the first two parts using the private key.
Base64URL-decode the Header:
{ "alg": "EdDSA", "typ": "JWT", "kid": "link-v2-active"}alg: Signing algorithm.kid: Key ID, telling the verifier which public key in the JWKS to use.
Base64URL-decode the Payload:
{ "sub": "123456", "iss": "https://link.sast.fun/v2", "aud": "client_id", "exp": 1700000000, "iat": 1700000000, "nonce": "abc123"}Verification Flow
Once the client receives an ID Token, it proceeds to verify it.
- Split it into three parts and Base64URL-decode the first two.
- Check the Header:
algmust be an accepted algorithm.kididentifies the public key.
- Get the public key:
- Fetch the JWKS from
/.well-known/jwks.json. - Find the public key matching
kid.
- Fetch the JWKS from
- Verify the signature:
- Use the public key to verify the third part’s signature.
- Verify the claims:
iss:=== OP issueraud: === your ownclient_id.exp: > the current time.iat: Must not be too early (to prevent replaying a very old token).nonce: === the originally generated value.
Verification order: Signature → exp → iss/aud/nonce.
kid and JWKS
kid ——> Key ID
Let’s take a look at Link’s JWKS:
{ "keys": [ { "kty": "OKP", "use": "sig", "kid": "link-v2-active", "crv": "Ed25519", "alg": "EdDSA", "x": "BFj6m10rdWe_1VZ6bMB7FTU034NNMUueRBuw8-Ah_B0" } ]}kid:link-v2-activekty:OKPmeans Octet Key Pair (RFC 8037).crv: TheEd25519elliptic curve.x: The public key coordinate, 32 bytes encoded as Base64URL.
The verifier uses x to verify the signature.
The private key d never appears in the JWKS.
Is an Access Token a JWT?
Not necessarily.
An access token can be:
- A self-contained JWT: The resource server verifies it offline without calling the authorization server.
- An opaque token: The resource server must call the Token Introspection endpoint to ask the authorization server to validate it.
Something else
PKCE Implementation Details
As mentioned earlier, 2.1 mandates PKCE.
The core of PKCE is binding an authorization code to a random value generated by the client.
Even if the authorization code is intercepted, the attacker cannot get a token.
The S256 Transformation
PKCE has two code_challenge_method values:
plain:code_challenge = code_verifierS256:code_challenge = BASE64URL(SHA256(code_verifier))(mandatory in 2.1).
Q: Why is plain insecure?
A: Because code_challenge appears in the authorization request URL.
The Full PKCE Flow
sequenceDiagram
autonumber
participant Client
participant Browser as User's browser
participant AS as Authorization Server
Note over Client: 1. Generate code_verifier<br/>A random string of 43-128 characters<br/>([A-Za-z0-9._~-])
Client->>Client: code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Note over Client: 2. Calculate code_challenge<br/>SHA256 + Base64URL
Client->>Client: code_challenge = BASE64URL(SHA256(code_verifier))
Client->>Browser: 3. Redirect to /authorize
Browser->>AS: 4. GET /authorize?<br/>response_type=code<br/>&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM<br/>&code_challenge_method=S256<br/>&client_id=xxx<br/>&redirect_uri=xxx<br/>&state=xyz
Note over AS: 5. Store code_challenge + method<br/>Bind them to the authorization code
AS-->>Browser: 6. User logs in + approves authorization
AS->>Browser: 7. 302 redirect to callback
Browser->>Client: 8. GET /callback?code=AUTH_CODE&state=xyz
Note over Client: 9. Verify state, prepare to exchange for token
Client->>AS: 10. POST /token<br/>grant_type=authorization_code<br/>&code=AUTH_CODE<br/>&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk<br/>&client_id=xxx<br/>&redirect_uri=xxx
Note over AS: 11. Verify:<br/>BASE64URL(SHA256(code_verifier))<br/>== stored code_challenge ?
alt Verification succeeds
AS-->>Client: 12. 200 OK (access_token + refresh_token)
else Verification fails
AS-->>Client: 12. 400 Bad Request (invalid_grant)
end
Pushed Authorization Requests (PAR)
Traditional authorization requests concatenate every parameter into the URL.
GET /authorize?response_type=code&client_id=xxx&redirect_uri=https://...&scope=openid+profile+email&state=xyz&nonce=abc&code_challenge=...&code_challenge_method=S256But does that really make complete sense?
In most cases, it’s actually fine. But there are still problems. (Well, why didn’t you say so earlier?)
- URL length limits: Browsers and proxies impose limits on URL length, and complex scopes or OIDC can blow past them.
- Parameter exposure: URLs appear in browser history, server logs, and Referer headers, so
state/noncecan still leak. - Parameter tampering: URL parameters can be modified (e.g. widening
scope).
I’m telling you, when in doubt, add an intermediary.
And so we got Pushed Authorization Requests.
The idea behind PAR: Push parameters to the authorization server with a backend POST, get a time-limited request_uri, and then authorize using that URI.
It’s really the same idea as a shortened URL.
sequenceDiagram
autonumber
participant Client
participant AS as Authorization Server
participant Browser as User's browser
Note over Client: 1. Prepare authorization parameters
Client->>AS: 2. POST /as/par<br/>Content-Type: application/x-www-form-urlencoded<br/>Authorization: Basic client_id:client_secret<br/><br/>response_type=code<br/>&client_id=xxx<br/>&redirect_uri=...<br/>&scope=openid profile email<br/>&state=xyz<br/>&nonce=abc<br/>&code_challenge=...<br/>&code_challenge_method=S256
Note over AS: 3. Authenticate client<br/>Store parameters and generate request_uri
AS-->>Client: 4. 200 OK<br/>{ "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",<br/> "expires_in": 90 }
Note over Client: 5. request_uri is valid for 90 seconds<br/>Single use only
Client->>Browser: 6. Redirect to /authorize
Browser->>AS: 7. GET /authorize?<br/>client_id=xxx<br/>&request_uri=urn:ietf:params:oauth:request_uri:6esc_...
Note over AS: 8. Retrieve stored parameters using request_uri<br/>Continue the standard authorization flow
AS-->>Browser: 9. Show login + consent page
PAR is worth implementing if you’re building a public-facing OAuth provider.
Token Introspection and Opaque Tokens
As mentioned earlier, an access token can be a JWT or an opaque token.
But if it’s the latter, how does the resource server know it’s valid?
The answer is Token Introspection (RFC 7662).
sequenceDiagram
autonumber
participant Client
participant RS as Resource Server
participant AS as Authorization Server
Client->>RS: 1. GET /api/data<br/>Authorization: Bearer opaque_token_xyz123
Note over RS: 2. Token is opaque<br/>Cannot parse it, must ask AS
RS->>AS: 3. POST /introspect<br/>Authorization: Basic rs_id:rs_secret<br/><br/>token=opaque_token_xyz123<br/>&token_type_hint=access_token
Note over AS: 4. Query database: Does token exist?<br/>Not revoked? Not expired?
alt Token is valid
AS-->>RS: 5. 200 OK<br/>{ "active": true,<br/> "scope": "read write",<br/> "client_id": "client_123",<br/> "username": "user@example.com",<br/> "exp": 1700000000,<br/> "sub": "user_id_456" }
else Token is invalid
AS-->>RS: 5. 200 OK<br/>{ "active": false }
end
alt active: true
RS-->>Client: 6. 200 OK (return protected resources)
else active: false
RS-->>Client: 6. 401 Unauthorized
end
Token Introspection makes things much more convenient.
- All in on the authorization server: Everything is queried and returned by the AS (Authorization Server) in real time.
- Immediate revocation: When a user logs out, the AS marks the token revoked, and the next introspection returns
active: false. - Resource servers must authenticate: Usually with a
client_secretat the introspection endpoint.
JWT vs Opaque :
| Dimension | JWT | Opaque Token |
|---|---|---|
| Resource server load | Low (offline verification, signature only) | High (introspection call on every request) |
| Immediate revocation | Hard (valid until expiration unless the resource server also queries the database) | Easy (AS marks it revoked; introspection returns false) |
| Information disclosure risk | High (Base64) | Low (opaque token carries no information, just a random string) |
| Passing across services | Easy (resource server A can forward the JWT to B) | Hard (each resource server must call the AS) |
For a distributed architecture, opaque tokens + introspection may be the better choice.
DPoP: Key-Bound Tokens
Earlier, we mentioned that refresh tokens must either rotate or be sender-constrained.
DPoP (Demonstrating Proof of Possession, RFC 9449) is one implementation of sender-constraining.
The Idea Behind DPoP
Bind the token to the client’s asymmetric key pair:
- The client generates a public/private key pair.
- When requesting a token, it provides the public key to the authorization server.
- The authorization server records the public key thumbprint in the token’s
cnf(confirmation) claim. - Every time the client accesses a resource with the token, it signs a proof with its private key (including the current request’s HTTP method, URL, and timestamp).
- The resource server checks whether the proof’s signature matches the public key in the token.
The Full Flow
sequenceDiagram
autonumber
participant Client
participant AS as Authorization Server
participant RS as Resource Server
Note over Client: 1. Generate an RSA or EC key pair
Client->>Client: Generate key pair (public + private)
Client->>AS: 2. POST /token<br/>grant_type=authorization_code<br/>&code=...<br/>&code_verifier=...<br/>DPoP: <DPoP proof JWT>
Note over Client: DPoP proof contains:<br/>{ "typ": "dpop+jwt",<br/> "alg": "ES256",<br/> "jwk": { client's public key } }<br/>.<br/>{ "jti": "uuid",<br/> "htm": "POST",<br/> "htu": "https://as.example.com/token",<br/> "iat": 1700000000 }<br/>. <private-key signature>
Note over AS: 3. Verify DPoP proof:<br/>- Does signature match public key?<br/>- Do htm/htu match current request?<br/>- Is iat within a reasonable range?
Note over AS: 4. Issue token and record the public key's<br/>JWK Thumbprint in the cnf claim
AS-->>Client: 5. 200 OK<br/>{ "access_token": "eyJ...", #35; contains cnf: { jkt: "thumbprint" }<br/> "token_type": "DPoP", #35; not Bearer!<br/> "expires_in": 3600 }
Client->>RS: 6. GET /api/data<br/>Authorization: DPoP eyJ...<br/>DPoP: <another DPoP proof JWT>
Note over Client: This DPoP proof:<br/>htm=GET, htu=/api/data<br/>Also includes ath (access token hash)
Note over RS: 7. Verify two layers:<br/>① Token signature and exp<br/>② DPoP proof signature, htm/htu/ath<br/>③ Proof public key thumbprint == jkt in token
alt Verification succeeds
RS-->>Client: 8. 200 OK (return data)
else Verification fails
RS-->>Client: 8. 401 Unauthorized<br/>WWW-Authenticate: DPoP error="invalid_proof"
end
The structure of a DPoP proof:
Header:
{ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }}Payload (when requesting /token):
{ "jti": "unique-uuid", "htm": "POST", "htu": "https://as.example.com/token", "iat": 1700000000}Payload (when requesting a resource):
{ "jti": "another-uuid", "htm": "GET", "htu": "https://rs.example.com/api/data", "iat": 1700000100, "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" // BASE64URL(SHA256(access_token))}Each request gets a fresh proof, because jti, iat, htm, and htu all change. Even if an attacker records a complete HTTP request (including the DPoP proof), replaying it against another endpoint fails (htu does not match).
Advantages of DPoP:
- Fully prevents stolen tokens from being used
- Replay protection
- No server-side state required
Costs of DPoP:
- Much more client complexity: A JWT must be signed for every request.
- Managing private keys on mobile devices/in browsers is a hassle.
- The resource server must verify two layers (token signature + proof signature), increasing latency.
Token Binding and mTLS
DPoP binds tokens using asymmetric keys. There is also a lower-level approach: mTLS (mutual TLS).
Traditional HTTPS only verifies the server certificate (the client trusts the server). mTLS requires the client to provide a certificate too (the server also verifies the client), binding the TCP connection itself to the client’s identity.
Binding OAuth Tokens with mTLS
sequenceDiagram
autonumber
participant Client
participant AS as Authorization Server
participant RS as Resource Server
Note over Client: 1. Client has its own TLS certificate<br/>(issued by enterprise PKI or self-signed)
Client->>AS: 2. POST /token (present client certificate during TLS handshake)
Note over AS: 3. TLS layer verifies client certificate<br/>Extract certificate thumbprint (SHA-256)
Note over AS: 4. Issue token and record<br/>client certificate thumbprint in cnf claim
AS-->>Client: 5. 200 OK (access_token)
Client->>RS: 6. GET /api/data (present the same certificate during TLS handshake)
Note over RS: 7. TLS layer verifies client certificate<br/>Extract certificate thumbprint
Note over RS: 8. Decode token and compare:<br/>token.cnf.x5t#35;S256 == TLS certificate thumbprint?
alt Thumbprints match
RS-->>Client: 9. 200 OK
else Thumbprints do not match
RS-->>Client: 9. 401 Unauthorized
end
Advantages of mTLS:
- Lower-level than DPoP: Binding happens at the TLS layer.
- Better performance: The TLS handshake happens only once when establishing the connection.
- Man-in-the-middle protection
Disadvantages of mTLS:
- Complex certificate management: Every client needs a certificate; issuance, distribution, rotation, and revocation all have a cost.
- Unfriendly to mobile/browser environments: Browser environments cannot access client certificates, and mobile apps must implement their own certificate storage.
- Load balancers/reverse proxies need special handling: Nginx and Cloudflare terminate TLS by default, so client certificates do not reach the backend.
ssl_client_certificatemust be configured to pass them through.
Device Flow: Authorization for Devices Without Browsers
The Device Authorization Grant (RFC 8628) is designed for devices without a browser or with awkward input methods.
Typical scenarios:
- Logging in to Netflix on a smart TV.
- CLI tools (
gh auth login,docker login). - Raspberry Pis and IoT devices.
How It Works
sequenceDiagram
autonumber
participant Device as Device (smart TV)
participant AS as Authorization Server
participant User as User (phone/computer)
Device->>AS: 1. POST /device_authorization<br/>client_id=tv_app
Note over AS: 2. Generate:<br/>device_code (for device polling)<br/>user_code (short code for user input)<br/>verification_uri (URL the user visits)
AS-->>Device: 3. 200 OK<br/>{ "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",<br/> "user_code": "WDJB-MJHT", #35; short and easy to enter<br/> "verification_uri": "https://example.com/device",<br/> "expires_in": 1800,<br/> "interval": 5 } #35; polling interval (seconds)
Note over Device: 4. Display:<br/>On your phone/computer, open<br/>https://example.com/device<br/>Enter code: WDJB-MJHT
Device->>Device: 5. Start polling (every 5 seconds)
User->>AS: 6. Open verification_uri and enter user_code
Note over AS: 7. Show login + authorization consent page
User->>AS: 8. Log in and approve authorization
loop Device polls
Device->>AS: 9. POST /token<br/>grant_type=urn:ietf:params:oauth:grant-type:device_code<br/>&device_code=GmRh...<br/>&client_id=tv_app
alt User has not authorized yet
AS-->>Device: 10. 400 Bad Request<br/>{ "error": "authorization_pending" }
else User denied authorization
AS-->>Device: 10. 400 Bad Request<br/>{ "error": "access_denied" }
else User has authorized
AS-->>Device: 10. 200 OK<br/>{ "access_token": "...",<br/> "refresh_token": "...",<br/> "expires_in": 3600 }
else device_code expired
AS-->>Device: 10. 400 Bad Request<br/>{ "error": "expired_token" }
end
end
Note over Device: 11. Obtain token and stop polling<br/>Start accessing API
Device Flow Security:
user_codeis short and easy to guess, so the authorization server must limit attempts (lock after 5 failures).device_codeis a high-entropy random value. The polling endpoint does not require client authentication (the device has noclient_secret).- The user enters their password on their own phone/computer. The device never sees it.
verification_urican be a complete URL (e.g.https://example.com/device?user_code=WDJB-MJHT), so the user can simply follow a link without typing the code manually.
Maybe we’ll implement this if there’s a Link CLI someday.
Cross-Origin Requests and CORS
OAuth flows often run into seemingly insurmountable cross-origin problems.
The Problem Scenario
Suppose we have:
- Authorization server:
https://auth.example.com - Frontend application:
https://app.example.com
Frontend JS calls /token directly to exchange the authorization code:
// That's bad...fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=authorization_code&code=...',})The browser sends a CORS preflight (OPTIONS request). If the authorization server does not return the correct CORS headers, the browser blocks the request.
solutions
Option 1: The authorization server returns CORS headers
Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: POSTAccess-Control-Allow-Headers: Content-TypeBut the /token endpoint serves all clients. Whose domain should go in Allow-Origin?
* is insecure, and handling multiple origins is a hassle.
Option 2: The BFF pattern (Backend For Frontend)
The frontend does not call the authorization server directly. It goes through its own backend:
sequenceDiagram
participant Browser as Browser
participant BFF as BFF (app.example.com/api)
participant AS as Authorization Server
Browser->>BFF: 1. POST /api/auth/callback<br/>code=AUTH_CODE
Note over BFF: 2. Same-origin request, no CORS problem
BFF->>AS: 3. POST /token<br/>code=AUTH_CODE<br/>&client_secret=...
Note over BFF: 4. Server-side request, no CORS restrictions
AS-->>BFF: 5. 200 OK (token)
BFF-->>Browser: 6. Set-Cookie: session=...<br/>Or return token (not recommended)
Benefits of a BFF:
- The frontend never directly handles
client_secret(for confidential clients). - The frontend never directly handles
refresh_token(it can be stored in the BFF’s session). - The authorization server does not need CORS configuration.
We use the BFF pattern in Link, zwz—the frontend receives the authorization code and calls /oauth/exchange-code (exchanging the login code for a token), a same-origin endpoint.
aud and azp in an ID Token
We covered aud earlier, but OIDC has a special case: one ID Token can have multiple audiences.
For example, a user authorizes three clients (A, B, C) to share the same ID Token, so aud is an array:
{ "iss": "https://op.example.com", "sub": "user123", "aud": ["client_a", "client_b", "client_c"], "azp": "client_a", "exp": 1700000000}Every client must verify that aud contains its own client_id.
But if you’re client_b, you also need to check whether azp (Authorized Party) is trusted—azp
Emerging Standards and Practices
RFC 9068: JWT Access Token Profile
OAuth 2.0 does not define a token format, but JWT has become the de facto standard in practice. RFC 9068 is the first RFC to formally standardize using JWTs as access tokens, defining a uniform claims structure.
Why RFC 9068
Before RFC 9068, every OP used its own format for JWT access tokens:
- Some used
scope, othersscp. - Some used
client_id, othersazporcid. - A resource server receiving a JWT access token did not know which claim represented permission scopes.
RFC 9068 standardizes these fields so resource servers can use the same logic to verify JWT access tokens from different OPs.
Standard Claims
| Claim | Required | Description |
|---|---|---|
iss | ✓ | Issuer (the OP’s URL) |
exp | ✓ | Expiration time |
aud | ✓ | Audience (resource server identifier; may be an array) |
sub | ✓ | Subject (user identifier) |
client_id | ✓ | Client ID (fixed field name) |
iat | ✓ | Issued-at time |
jti | ✓ | Token ID |
scope | — | Permission scopes (space-separated string); required for the client credentials grant |
auth_time | — | Time the user actually authenticated |
acr / amr | — | Authentication context / authentication methods |
example
{ "iss": "https://op.example.com", "sub": "user123", "aud": "https://api.example.com", "client_id": "my_client", "scope": "openid profile email", "exp": 1700003600, "iat": 1700000000, "jti": "550e8400-e29b-41d4-a716-446655440000"}RFC 9207: OAuth 2.0 Authorization Server Issuer Identification
When redirecting back to the client, the authorization server must include the iss (issuer) parameter.
Mix-Up Attack solution
Suppose your application supports two OAuth login providers: Google and Facebook.
- The user clicks ‘Log in with Google’. The client generates
state=abcand redirects to Google’s/authorize. - An attacker in the middle tampers with the redirect, changing the URL to Facebook’s
/authorize, whilestateandredirect_uristill belong to the client. - The user authorizes on Facebook, which sends the authorization code back to the client’s
redirect_uri?code=xyz&state=abc. - The client sees that
state=abcmatches, assumes this is a Google authorization code, and takes it to Google’s/tokento exchange for a token. - Google rejects it, but the attacker has already achieved their goal: the client has leaked Facebook’s authorization code in the request to Google’s
/token.
RFC 9207 requires the authorization server to include an iss parameter when redirecting:
https://client.example.com/callback?code=xyz&state=abc&iss=https://facebook.comUpon receiving it, the client checks whether iss matches the authorization server URL recorded when authorization started:
Revoking JWT Access Tokens: jti and a Denylist
RFC 9068 requires JWT access tokens to have a jti precisely to address the problem of ‘self-contained JWT = cannot be revoked’.
The JWT Revocation Dilemma
A JWT is self-contained: When the resource server receives it, a valid signature and an unexpired exp are enough to consider it valid.
But this creates a problem: If a user changes their password or an administrator bans their account, the JWT still works until it expires, because the resource server never queries the database.
Link’s Approach
Link’s JWT access tokens are self-contained, but the resource server queries the database on every request:
func (a *Authenticator) RequireAdminAuth(ctx context.Context, header string) (Principal, error) { // 1. Verify JWT signature, exp, and aud claims := verifyJWT(header)
// 2. Read token metadata from the database tokenMeta := db.QueryOne("SELECT revoked_at FROM oauth_access_tokens WHERE jti = ?", claims.JTI) if tokenMeta.RevokedAt != nil { return nil, ErrTokenRevoked }
// 3. Read the user's current state from the database user := db.QueryOne("SELECT role, state, token_version FROM user WHERE id = ?", claims.Sub) if user.TokenVersion != claims.TokenVersion { return nil, ErrTokenVersionMismatch // Old token after a password change } if user.State == "is_deleted" { return nil, ErrAccountClosed }
// 4. Check permissions if user.Role != "admin" && user.Role != "lecturer" { return nil, ErrForbidden }
return Principal{UserID: claims.Sub, Role: user.Role}, nil}revoked_atdenylist: A password change, logout, or administrator ban immediately writesoauth_access_tokens.revoked_at, so the next request is rejected.token_versionglobal version number: A password change, privilege reduction, or account closure incrementsuser.token_version, invalidating all JWTs with older versions.statelive status: Every request readsuser.statefrom the database, so an account ban (is_deleted) takes effect immediately.
If the AS and RS are separate:
- The RS can cache the revocation status of
jtiin Redis, with the TTL set to the JWT’s remaining lifetime. - On a password change/account ban, the AS writes to the database and also writes Redis
SET jti:xxx "revoked" EX 3600. - When verifying a JWT, the RS checks Redis first and queries the database only on a miss.
RFC 9396: OAuth 2.0 Rich Authorization Requests (RAR)
Traditional OAuth scope is a flat list of strings:
scope=read:email write:posts delete:postsBut some scenarios need finer-grained permissions:
- ‘Read orders from 2024, but not from 2023.’
- ‘Access only
/api/users/me, not/api/users/{id}.’ - ‘Access only on weekdays from 9:00 to 18:00 .’
RFC 9396 lets clients send structured permission descriptions in authorization requests instead of just scope strings.
RAR Format
At /authorize, a client can send authorization_details (a JSON array) in addition to scope:
{ "authorization_details": [ { "type": "payment", "actions": ["initiate", "cancel"], "locations": ["https://api.example.com/payments"], "max_amount": 1000, "currency": "USD" }, { "type": "account_information", "actions": ["read"], "accounts": ["account-123", "account-456"] } ]}When issuing the access token, the authorization server writes the portion the user approved into the token (either as a JWT claim or as metadata associated with an opaque token).
When the resource server receives the token, it parses authorization_details to determine whether the current request falls within the authorized scope.
Why RAR
Traditional scope has limitations.
RAR’s authorization_details can express arbitrary structures:
typeis the authorization type (custom, defined by the resource server).- Other fields depend on
type(e.g. apaymenttype hasmax_amount, while anaccount_informationtype hasaccounts).
Completeness Checks for Discovery and JWKS
OIDC’s Discovery document is the standard entry point. A client can learn all the endpoints by fetching /.well-known/openid-configuration.
$ curl -s https://link.sast.fun/v2/.well-known/openid-configuration | jq .{ "issuer": "https://link.sast.fun/v2", "authorization_endpoint": "https://link.sast.fun/v2/oauth/authorize", "token_endpoint": "https://link.sast.fun/v2/oauth/token", "userinfo_endpoint": "https://link.sast.fun/v2/userinfo", "jwks_uri": "https://link.sast.fun/v2/.well-known/jwks.json", "revocation_endpoint": "https://link.sast.fun/v2/oauth/revoke", "scopes_supported": [ "openid", "profile", "email", "admin:read", "admin:write", "user:read", "user:write" ], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["EdDSA"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], "claims_supported": [ "sub", "iss", "aud", "exp", "iat", "nonce", "name", "picture", "preferred_username", "role", "email", "email_verified", "updated_at" ], "code_challenge_methods_supported": ["S256"], "response_modes_supported": ["query"], "claim_types_supported": ["normal"], "request_parameter_supported": false, "request_uri_parameter_supported": false, "claims_parameter_supported": false}OIDC Discovery 1.0 classifies fields as REQUIRED, RECOMMENDED, or OPTIONAL.
Checking the JWKS Response
$ curl -s https://link.sast.fun/v2/.well-known/jwks.json | jq .{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "kid": "link-v2-active", "use": "sig", "alg": "EdDSA", "x": "BFj6m10rdWe_1VZ6bMB7FTU034NNMUueRBuw8-Ah_B0" } ]}Let’s Code, Code, Code an OIDC Client
Suppose we’re writing a third-party application that uses Link to log in.
Step 1: Read the Discovery Document
const discovery = await fetch( 'https://link.sast.fun/v2/.well-known/openid-configuration',).then((r) => r.json())
console.log(discovery.authorization_endpoint) // https://link.sast.fun/v2/oauth/authorizeconsole.log(discovery.token_endpoint) // https://link.sast.fun/v2/oauth/tokenconsole.log(discovery.userinfo_endpoint) // https://link.sast.fun/v2/userinfoWhat Discovery tells us:
- ✓ Supports the authorization code flow (
response_types_supported: ["code"]). - ✓ Requires PKCE (
code_challenge_methods_supported: ["S256"]). - ✓ Supports refresh tokens (
grant_types_supportedincludesrefresh_token). - ✗ Does not support
client_secret_basic(token_endpoint_auth_methods_supportedcontains onlynoneandclient_secret_post).
Step 2: Generate PKCE Parameters
// Generate a random code_verifier of 43-128 bytesfunction generateCodeVerifier() { const array = new Uint8Array(32) // 32 bytes = 43 base64url characters crypto.getRandomValues(array) return base64urlEncode(array)}
// S256: code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))async function generateCodeChallenge(verifier) { const encoder = new TextEncoder() const data = encoder.encode(verifier) const hash = await crypto.subtle.digest('SHA-256', data) return base64urlEncode(new Uint8Array(hash))}
const codeVerifier = generateCodeVerifier()const codeChallenge = await generateCodeChallenge(codeVerifier)
// Store in sessionStorage for use when the callback returnssessionStorage.setItem('pkce_verifier', codeVerifier)Step 3: Redirect to the Authorization Endpoint
const state = generateRandomString() // Prevent CSRFconst nonce = generateRandomString() // Prevent ID Token replay
sessionStorage.setItem('oauth_state', state)sessionStorage.setItem('oauth_nonce', nonce)
const authUrl = new URL(discovery.authorization_endpoint)authUrl.searchParams.set('response_type', 'code')authUrl.searchParams.set('client_id', 'my-app-client-id')authUrl.searchParams.set('redirect_uri', 'https://my-app.com/callback')authUrl.searchParams.set('scope', 'openid profile email')authUrl.searchParams.set('state', state)authUrl.searchParams.set('nonce', nonce)authUrl.searchParams.set('code_challenge', codeChallenge)authUrl.searchParams.set('code_challenge_method', 'S256')
window.location.href = authUrl.hrefStep 4: Handle the Callback
// After the user authorizes, Link redirects to https://my-app.com/callback?code=xxx&state=yyy
const urlParams = new URLSearchParams(window.location.search)const code = urlParams.get('code')const state = urlParams.get('state')
// Verify stateif (state !== sessionStorage.getItem('oauth_state')) { throw new Error('State mismatch (CSRF detected)')}
// Exchange for tokensconst codeVerifier = sessionStorage.getItem('pkce_verifier')const tokenResponse = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: 'https://my-app.com/callback', client_id: 'my-app-client-id', client_secret: 'my-secret', // For a confidential client code_verifier: codeVerifier, }),})
const tokens = await tokenResponse.json()console.log(tokens)// {// access_token: "eyJhbGciOi...",// token_type: "Bearer",// expires_in: 3600,// refresh_token: "...",// id_token: "eyJhbGciOi...",// scope: "openid profile email"// }Step 5: Verify the ID Token
// 1. Split the JWTconst [headerB64, payloadB64, signatureB64] = tokens.id_token.split('.')const header = JSON.parse(base64urlDecode(headerB64))const payload = JSON.parse(base64urlDecode(payloadB64))
// 2. Get the public key from JWKSconst jwks = await fetch(discovery.jwks_uri).then((r) => r.json())const key = jwks.keys.find((k) => k.kid === header.kid)if (!key) throw new Error('kid not found in JWKS')
// 3. Verify the signature (using the Web Crypto API)const publicKey = await crypto.subtle.importKey( 'jwk', key, { name: 'EdDSA', namedCurve: 'Ed25519' }, false, ['verify'],)
const signatureValid = await crypto.subtle.verify( 'EdDSA', publicKey, base64urlDecode(signatureB64), new TextEncoder().encode(headerB64 + '.' + payloadB64),)
if (!signatureValid) throw new Error('Signature verification failed')
// 4. Verify claimsif (payload.iss !== discovery.issuer) throw new Error('iss mismatch')if (payload.aud !== 'my-app-client-id') throw new Error('aud mismatch')if (payload.exp < Date.now() / 1000) throw new Error('ID Token expired')if (payload.nonce !== sessionStorage.getItem('oauth_nonce')) throw new Error('nonce mismatch')
// 5. Extract user informationconsole.log('User ID:', payload.sub)console.log('Name:', payload.name)console.log('Email:', payload.email)Step 6: Call UserInfo (Optional)
// Claims in the ID Token are a compact version; call UserInfo for a complete profileconst userinfo = await fetch(discovery.userinfo_endpoint, { headers: { Authorization: `Bearer ${tokens.access_token}` },}).then((r) => r.json())
console.log(userinfo)// {// sub: "250",// name: "Sean",// email: "woshinailong@sast.fun",// email_verified: true,// picture: "https://sast-link-1309205610.cos.ap-shanghai.myqcloud.com/avatar/9.jpg",// preferred_username: "zs",// role: "admin",// updated_at: 1700000000// }Step 7: Refresh Token
// After the Access Token expires (1 hour), exchange the Refresh Token for a new oneconst refreshResponse = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: 'my-app-client-id', client_secret: 'my-secret', // For a confidential client }),})
const newTokens = await refreshResponse.json()// {// access_token: "eyJhbGciOi...", // New// token_type: "Bearer",// expires_in: 3600,// refresh_token: "...", // New (rotation)// scope: "openid profile email"// }
// The old refresh_token has been revoked; use the new one next timeStep 8: Logout
// Link's logout is an internal endpoint, not standard OIDC RP-Initiated Logout// If you hold a Link access token (internal session), you can call:await fetch('https://link.sast.fun/v2/auth/logout', { method: 'POST', headers: { Authorization: `Bearer ${tokens.access_token}` },})
// A third-party application only needs to clear local tokens; there is no need to notify LinksessionStorage.removeItem('access_token')sessionStorage.removeItem('refresh_token')