This project demonstrates OAuth 2.0 Authorization Code Flow with PKCE (Proof Key for Code Exchange) using three Node.js apps:
auth-server(Authorization Server) onhttp://localhost:3100client(OAuth Client / Relying Party) onhttp://localhost:5100resource-server(Protected API) onhttp://localhost:4100
oauth2-learning/
auth-server/
index.js
package.json
client/
index.js
package.json
resource-server/
index.js
package.json
client_id:mimic-acx-clientissuer:http://localhost:3100redirect_uri:http://localhost:5100/callbackresource audience:mimic-acx-client
flowchart LR
U[User Browser]
subgraph C[Client App - localhost:5100]
C1[GET /]
C2[GET /login]
C3[GET /callback]
C4[GET /profile]
C5[GET /refresh]
CStore[(HTTP only cookies<br/>code_verifier oauth_state<br/>access_token refresh_token)]
end
subgraph A[Authorization Server - localhost:3100]
A1[GET /authorize]
A2[POST /token]
A3[GET /.well-known/jwks.json]
AStore1[(clients)]
AStore2[(authorizationCode)]
AStore3[(refreshTokens)]
AKey[(private.pem/public.pem)]
end
subgraph R[Resource Server - localhost:4100]
R1[GET /api/profile]
RJWKS[Remote JWKS cache]
end
U -->|open app + click login| C1
C1 --> C2
C2 --> CStore
C2 -->|302 redirect with PKCE + state| A1
A1 --> AStore1
A1 --> AStore2
A1 -->|302 back with code + state| C3
C3 --> CStore
C3 -->|POST /token authorization_code| A2
A2 --> AStore2
A2 --> AStore3
A2 --> AKey
A2 -->|access_token + refresh_token| C3
C3 --> CStore
C4 -->|Bearer access_token| R1
R1 --> RJWKS
RJWKS -->|fetch keys| A3
C5 -->|POST /token refresh_token| A2
A2 -->|new access_token| C5
C5 --> CStore
sequenceDiagram
autonumber
participant U as User Browser
participant C as Client (5100)
participant A as Auth Server (3100)
participant R as Resource Server (4100)
U->>C: GET /
U->>C: GET /login
C->>C: Generate code_verifier, code_challenge(S256), state
C->>U: Set cookies(code_verifier, oauth_state)
C->>A: Redirect to GET /authorize with PKCE + state
A->>A: Validate client_id, redirect_uri, response_type, PKCE
A->>A: Store authorization code (short TTL)
A->>C: Redirect /callback?code=...&state=...
U->>C: GET /callback?code&state
C->>C: Validate state from cookie
C->>A: POST /token (authorization_code + code_verifier)
A->>A: Validate code + PKCE + redirect_uri + client_id
A->>A: Delete one-time code
A->>A: Sign JWT access token (RS256, private.pem)
A->>A: Store refresh token
A-->>C: access_token + refresh_token
C->>U: Set cookies(access_token, refresh_token)
U->>C: GET /profile
C->>R: GET /api/profile (Authorization: Bearer access_token)
R->>A: GET /.well-known/jwks.json (if key not cached)
R->>R: Verify JWT signature + issuer + audience + api.read scope
R-->>C: Protected profile JSON
C-->>U: Render profile
U->>C: GET /refresh
C->>A: POST /token (refresh_token)
A->>A: Validate refresh_token + client_id
A->>A: Sign new access token
A-->>C: new access_token
C->>U: Update access_token cookie
Responsibilities:
- Handles
/authorizeand/token - Validates client, redirect URI, PKCE challenge/verifier
- Issues access tokens and refresh tokens
- Exposes JWKS at
/.well-known/jwks.jsonfor token signature verification
In-memory stores:
clients: registered OAuth clientsauthorizationCode: temporary auth codesrefreshTokens: refresh-token records
Main endpoints:
GET /authorizePOST /tokenGET /.well-known/jwks.json
Responsibilities:
- Starts login (
/login) by generating PKCE values and redirecting to auth server - Handles callback (
/callback) and exchanges code for tokens - Stores tokens in cookies
- Calls protected resource (
/profile) - Refreshes access token (
/refresh)
Main endpoints:
GET /GET /loginGET /callbackGET /profileGET /refresh
Responsibilities:
- Verifies Bearer access tokens via auth server JWKS
- Enforces issuer/audience checks
- Enforces required scope (
api.read) - Returns protected profile data
Main endpoint:
GET /api/profile
- User opens
clientand clicks Login. clientcreates:code_verifiercode_challenge = SHA256(code_verifier)(base64url)state
clientredirects browser toauth-server /authorizewith query params:response_type=codeclient_idredirect_uriscope(e.g.api.read openid profile email)statecode_challenge,code_challenge_method=S256
auth-servervalidates request, issues authorization code, redirects back toclient /callback?code=...&state=....clientcallsauth-server /tokenwith:grant_type=authorization_codecoderedirect_uriclient_idcode_verifier
auth-servervalidates PKCE and issues:access_tokenrefresh_token
clientcallsresource-server /api/profilewithAuthorization: Bearer <access_token>.resource-serververifies JWT via JWKS and checksapi.readscope.
Refresh flow:
clientsendsgrant_type=refresh_tokentoauth-server /token.auth-servervalidates refresh token and returns a new access token.
From project root:
cd auth-server && npm install
cd ../client && npm install
cd ../resource-server && npm installFor auth-server key setup, see create private / public key with openssl.
From project root:
npm run devThen open: http://localhost:5100
Use three terminals:
# terminal 1
cd auth-server && npm run dev# terminal 2
cd resource-server && npm run dev# terminal 3
cd client && npm run devUse three terminals:
# terminal 1
cd auth-server && npm start# terminal 2
cd resource-server && npm start# terminal 3
cd client && npm startOr run all three with one command from project root:
npm start- This is a learning/demo setup, not production hardening.
- State, authorization codes, and refresh tokens are stored in memory (lost on restart).
- The signing keys are saved as local files (
private.pem/public.pem) inside this project. - In production, many teams keep private keys in a managed key service or hardware module (KMS/HSM) instead of plain files for stronger security.
- Resource server checks that
api.readis present; extra scopes are allowed.