OAuth/OIDC token-boundary lab
Exercises issuer, audience, time, nonce, state, redirects, PKCE, and key rotation against local boundary fixtures.
Application Security 3 min read
Implementation: Tested
Implementation
labs/oauth-oidc/oauth-security.js
1"use strict";
2
3const {
4 createSign,
5 createVerify,
6} = require("node:crypto");
7
8const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
9
10class TokenValidationError extends Error {
11 constructor(code, message) {
12 super(message);
13 this.name = "TokenValidationError";
14 this.code = code;
15 }
16}
17
18function encodeJson(value) {
19 return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
20}
21
22function decodeJsonSegment(segment, label) {
23 if (!segment || !BASE64URL_PATTERN.test(segment)) {
24 throw new TokenValidationError("MALFORMED_TOKEN", `${label} is not base64url`);
25 }
26
27 try {
28 const decoded = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
29 if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
30 throw new Error("decoded value is not an object");
31 }
32 return decoded;
33 } catch {
34 throw new TokenValidationError("MALFORMED_TOKEN", `${label} is not a JSON object`);
35 }
36}
37
38function signJwt(claims, { privateKey, kid }) {
39 if (!privateKey || typeof kid !== "string" || kid.length === 0) {
40 throw new TypeError("privateKey and a nonempty kid are required");
41 }
42
43 const header = encodeJson({ alg: "RS256", kid, typ: "JWT" });
44 const payload = encodeJson(claims);
45 const signingInput = `${header}.${payload}`;
46 const signer = createSign("RSA-SHA256");
47 signer.update(signingInput, "ascii");
48 signer.end();
49 return `${signingInput}.${signer.sign(privateKey).toString("base64url")}`;
50}
51
52function parseScopeClaim(claims) {
53 const value = claims.scope ?? claims.scp;
54 if (typeof value !== "string") {
55 throw new TokenValidationError("INVALID_SCOPE", "scope/scp must be a string");
56 }
57 return new Set(value.split(" ").filter(Boolean));
58}
59
60function audienceContains(audience, expectedAudience) {
61 if (typeof audience === "string") return audience === expectedAudience;
62 if (Array.isArray(audience)) {
63 return audience.length > 0 &&
64 audience.every((entry) => typeof entry === "string") &&
65 audience.includes(expectedAudience);
66 }
67 return false;
68}
69
70function validateAccessToken(token, config) {
71 const {
72 issuer,
73 audience,
74 tenant,
75 requiredScopes = [],
76 trustedKeys,
77 now = Math.floor(Date.now() / 1000),
78 clockSkewSeconds = 0,
79 } = config;
80
81 if (!(trustedKeys instanceof Map) || trustedKeys.size === 0) {
82 throw new TypeError("trustedKeys must be a nonempty Map");
83 }
84 if (!Number.isInteger(now) || !Number.isInteger(clockSkewSeconds) ||
85 clockSkewSeconds < 0) {
86 throw new TypeError("time settings must be integer seconds");
87 }
88
89 const parts = typeof token === "string" ? token.split(".") : [];
90 if (parts.length !== 3 || !parts[2] || !BASE64URL_PATTERN.test(parts[2])) {
91 throw new TokenValidationError("MALFORMED_TOKEN", "JWT must have three segments");
92 }
93
94 const header = decodeJsonSegment(parts[0], "header");
95 const claims = decodeJsonSegment(parts[1], "payload");
96 if (header.alg !== "RS256" || header.typ !== "JWT" ||
97 typeof header.kid !== "string" || header.kid.length === 0) {
98 throw new TokenValidationError(
99 "UNSUPPORTED_HEADER",
100 "alg=RS256, typ=JWT, and a trusted kid are required",
101 );
102 }
103 if (header.jku !== undefined || header.x5u !== undefined ||
104 header.crit !== undefined) {
105 throw new TokenValidationError(
106 "UNSUPPORTED_HEADER",
107 "remote key URLs and unconfigured critical headers are rejected",
108 );
109 }
110
111 const key = trustedKeys.get(header.kid);
112 if (!key) {
113 throw new TokenValidationError("UNKNOWN_KEY", "kid is not in the trusted key set");
114 }
115
116 const verifier = createVerify("RSA-SHA256");
117 verifier.update(`${parts[0]}.${parts[1]}`, "ascii");
118 verifier.end();
119 if (!verifier.verify(key, Buffer.from(parts[2], "base64url"))) {
120 throw new TokenValidationError("INVALID_SIGNATURE", "signature verification failed");
121 }
122
123 if (claims.iss !== issuer) {
124 throw new TokenValidationError("INVALID_ISSUER", "issuer does not match");
125 }
126 if (!audienceContains(claims.aud, audience)) {
127 throw new TokenValidationError("INVALID_AUDIENCE", "audience does not match");
128 }
129 if (typeof claims.sub !== "string" || claims.sub.length === 0) {
130 throw new TokenValidationError("INVALID_SUBJECT", "nonempty sub is required");
131 }
132 if (!Number.isInteger(claims.exp) || now >= claims.exp + clockSkewSeconds) {
133 throw new TokenValidationError("TOKEN_EXPIRED", "token is expired");
134 }
135 if (claims.nbf !== undefined &&
136 (!Number.isInteger(claims.nbf) || now + clockSkewSeconds < claims.nbf)) {
137 throw new TokenValidationError("TOKEN_NOT_ACTIVE", "token is not active");
138 }
139 if (claims.tid !== tenant) {
140 throw new TokenValidationError("INVALID_TENANT", "tenant does not match");
141 }
142
143 const grantedScopes = parseScopeClaim(claims);
144 for (const requiredScope of requiredScopes) {
145 if (!grantedScopes.has(requiredScope)) {
146 throw new TokenValidationError(
147 "INSUFFICIENT_SCOPE",
148 `required scope is absent: ${requiredScope}`,
149 );
150 }
151 }
152
153 return Object.freeze({
154 issuer: claims.iss,
155 subject: claims.sub,
156 tenant: claims.tid,
157 scopes: Object.freeze([...grantedScopes]),
158 });
159}
160
161function validateRegisteredRedirect(uri) {
162 if (typeof uri !== "string" || uri.length === 0) {
163 throw new TypeError("registered redirect URI must be a nonempty string");
164 }
165
166 let parsed;
167 try {
168 parsed = new URL(uri);
169 } catch {
170 throw new TypeError("registered redirect URI must be an absolute URI");
171 }
172 if (parsed.protocol !== "https:" || parsed.username || parsed.password ||
173 parsed.hash) {
174 throw new TypeError(
175 "this web-client lab requires HTTPS without userinfo or a fragment",
176 );
177 }
178
179 // URL serialization can normalize meaningful syntax. Registration is accepted
180 // only when it is already in canonical serialized form; authorization requests
181 // are still compared to the original string, without normalization.
182 if (parsed.href !== uri) {
183 throw new TypeError("registered redirect URI must use canonical URL syntax");
184 }
185}
186
187function createExactRedirectMatcher(registeredUris) {
188 if (!Array.isArray(registeredUris) || registeredUris.length === 0) {
189 throw new TypeError("registeredUris must be a nonempty array");
190 }
191 for (const uri of registeredUris) validateRegisteredRedirect(uri);
192 const exactValues = new Set(registeredUris);
193 if (exactValues.size !== registeredUris.length) {
194 throw new TypeError("registered redirect URIs must be unique");
195 }
196 return (requestedUri) =>
197 typeof requestedUri === "string" && exactValues.has(requestedUri);
198}
199
200module.exports = {
201 TokenValidationError,
202 createExactRedirectMatcher,
203 signJwt,
204 validateAccessToken,
205};
1"use strict";
2
3const assert = require("node:assert/strict");
4const { generateKeyPairSync } = require("node:crypto");
5const {
6 TokenValidationError,
7 createExactRedirectMatcher,
8 signJwt,
9 validateAccessToken,
10} = require("../oauth-security");
11
12const NOW = 2_000_000_000;
13const ISSUER = "https://issuer.example.test";
14const AUDIENCE = "api://payments";
15const TENANT = "tenant-blue";
16const KID_OLD = "issuer-key-2026-01";
17const KID_NEW = "issuer-key-2026-07";
18
19const oldKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
20const newKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
21const attackerKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
22
23const baselineClaims = Object.freeze({
24 aud: AUDIENCE,
25 exp: NOW + 300,
26 iss: ISSUER,
27 nbf: NOW - 30,
28 scope: "payments.read payments.refund",
29 sub: "user-42",
30 tid: TENANT,
31});
32
33function token(overrides = {}, signing = {}) {
34 return signJwt(
35 { ...baselineClaims, ...overrides },
36 {
37 privateKey: signing.privateKey ?? oldKeys.privateKey,
38 kid: signing.kid ?? KID_OLD,
39 },
40 );
41}
42
43function config(overrides = {}) {
44 return {
45 audience: AUDIENCE,
46 clockSkewSeconds: 0,
47 issuer: ISSUER,
48 now: NOW,
49 requiredScopes: ["payments.read"],
50 tenant: TENANT,
51 trustedKeys: new Map([[KID_OLD, oldKeys.publicKey]]),
52 ...overrides,
53 };
54}
55
56function expectCode(expectedCode, operation) {
57 assert.throws(
58 operation,
59 (error) =>
60 error instanceof TokenValidationError && error.code === expectedCode,
61 `expected ${expectedCode}`,
62 );
63}
64
65const tests = [];
66function test(name, operation) {
67 tests.push({ name, operation });
68}
69
70test("accepts a correctly signed token with the configured security context", () => {
71 const principal = validateAccessToken(token(), config());
72 assert.deepEqual(
73 principal,
74 {
75 issuer: ISSUER,
76 scopes: ["payments.read", "payments.refund"],
77 subject: "user-42",
78 tenant: TENANT,
79 },
80 );
81});
82
83test("rejects a token signed by an attacker under a trusted kid", () => {
84 const forged = token({}, { privateKey: attackerKeys.privateKey, kid: KID_OLD });
85 expectCode("INVALID_SIGNATURE", () => validateAccessToken(forged, config()));
86});
87
88test("rejects the wrong issuer even when the signature is trusted", () => {
89 expectCode(
90 "INVALID_ISSUER",
91 () => validateAccessToken(token({ iss: "https://other.example.test" }), config()),
92 );
93});
94
95test("rejects the wrong audience", () => {
96 expectCode(
97 "INVALID_AUDIENCE",
98 () => validateAccessToken(token({ aud: "api://inventory" }), config()),
99 );
100});
101
102test("accepts the configured audience in an audience array", () => {
103 const principal = validateAccessToken(
104 token({ aud: ["api://reports", AUDIENCE] }),
105 config(),
106 );
107 assert.equal(principal.subject, "user-42");
108});
109
110test("rejects an expired token at the exp boundary", () => {
111 expectCode(
112 "TOKEN_EXPIRED",
113 () => validateAccessToken(token({ exp: NOW }), config()),
114 );
115});
116
117test("rejects a token whose not-before time is in the future", () => {
118 expectCode(
119 "TOKEN_NOT_ACTIVE",
120 () => validateAccessToken(token({ nbf: NOW + 1 }), config()),
121 );
122});
123
124test("rejects a token from the wrong tenant", () => {
125 expectCode(
126 "INVALID_TENANT",
127 () => validateAccessToken(token({ tid: "tenant-red" }), config()),
128 );
129});
130
131test("rejects a token missing a required scope", () => {
132 expectCode(
133 "INSUFFICIENT_SCOPE",
134 () => validateAccessToken(token({ scope: "payments.write" }), config()),
135 );
136});
137
138test("accepts old and new keys during a bounded rotation overlap", () => {
139 const overlap = config({
140 trustedKeys: new Map([
141 [KID_OLD, oldKeys.publicKey],
142 [KID_NEW, newKeys.publicKey],
143 ]),
144 });
145 assert.equal(validateAccessToken(token(), overlap).subject, "user-42");
146 assert.equal(
147 validateAccessToken(
148 token({}, { privateKey: newKeys.privateKey, kid: KID_NEW }),
149 overlap,
150 ).subject,
151 "user-42",
152 );
153});
154
155test("rejects the retired key after the overlap window", () => {
156 const afterRotation = config({
157 trustedKeys: new Map([[KID_NEW, newKeys.publicKey]]),
158 });
159 expectCode(
160 "UNKNOWN_KEY",
161 () => validateAccessToken(token(), afterRotation),
162 );
163 assert.equal(
164 validateAccessToken(
165 token({}, { privateKey: newKeys.privateKey, kid: KID_NEW }),
166 afterRotation,
167 ).subject,
168 "user-42",
169 );
170});
171
172test("does not fetch or accept an unknown kid", () => {
173 const unknownKey = token(
174 {},
175 { privateKey: attackerKeys.privateKey, kid: "https://attacker.test/key" },
176 );
177 expectCode("UNKNOWN_KEY", () => validateAccessToken(unknownKey, config()));
178});
179
180test("accepts only the exact registered redirect string", () => {
181 const isAllowed = createExactRedirectMatcher([
182 "https://app.example.test/oauth/callback?channel=web",
183 ]);
184 assert.equal(
185 isAllowed("https://app.example.test/oauth/callback?channel=web"),
186 true,
187 );
188
189 const nearMatches = [
190 "https://APP.example.test/oauth/callback?channel=web",
191 "https://app.example.test:443/oauth/callback?channel=web",
192 "https://app.example.test/oauth/callback/?channel=web",
193 "https://app.example.test/oauth/callback?CHANNEL=web",
194 "https://app.example.test/oauth/callback?channel=web#fragment",
195 "https://app.example.test@attacker.test/oauth/callback?channel=web",
196 "https://evil.app.example.test/oauth/callback?channel=web",
197 ];
198 for (const nearMatch of nearMatches) {
199 assert.equal(isAllowed(nearMatch), false, nearMatch);
200 }
201});
202
203test("rejects unsafe values at redirect registration time", () => {
204 assert.throws(
205 () => createExactRedirectMatcher(["http://app.example.test/callback"]),
206 /HTTPS/u,
207 );
208 assert.throws(
209 () => createExactRedirectMatcher(["https://user@app.example.test/callback"]),
210 /userinfo/u,
211 );
212});
213
214let failures = 0;
215for (const item of tests) {
216 try {
217 item.operation();
218 console.log(`PASS: ${item.name}`);
219 } catch (error) {
220 failures += 1;
221 console.error(`FAIL: ${item.name}`);
222 console.error(error.stack || error.message);
223 }
224}
225
226if (failures > 0) {
227 console.error(`OAuth/OIDC lab failed: ${failures} of ${tests.length} checks failed.`);
228 process.exit(1);
229}
230console.log(`OAuth/OIDC lab passed: ${tests.length} positive and negative checks.`);
Run it
node --test labs/oauth-oidc/tests/oauth-security.test.js
Implementation status: Tested locally on 2026-07-23 with Node.js 24.12.0 and Go 1.26.1. The JavaScript suite supports Node.js 22 or newer.
This dependency-free lab turns the most important OAuth/OIDC resource-server boundaries into executable acceptance and rejection checks. It is deliberately a small verifier model, not a replacement for a maintained JOSE/OIDC library.
What the lab proves
tests/oauth-security.test.js generates trusted, rotated, and attacker RSA keys and checks that the resource-server validator:
- accepts a correctly signed RS256 access token for the configured issuer, API audience, tenant, and required scope;
- rejects a bad signature, issuer, audience, expiration, not-before time, tenant, or scope;
- accepts the configured audience in either the string or array form;
- accepts old and new trusted keys during a bounded rotation overlap, then rejects the retired key;
- rejects an unknown
kidwithout turning it into a network location; and - compares web redirect URI requests to registered strings exactly, including case, port, path, query, and fragment differences.
The tid and scope/scp names are an explicit example contract. Real providers can use different claims, token profiles, and authorization semantics. Configure those from trusted provider documentation. The API must still enforce current object, tenant, and business authorization after token validation.
Run
From the repository root:
node labs/oauth-oidc/tests/oauth-security.test.js
go test ./appsec/scripts/oauth-pkce
Expected results are 14 passing Node.js checks and 8 passing Go checks. The Go program covers the RFC 7636 Appendix B vector, generated-verifier round trip, wrong verifier, plain downgrade, method case, length, syntax, and malformed challenge. It never prints verifier or challenge values.
Security boundaries and limitations
- The lab trusts only keys supplied in the configured
Map; it performs no discovery or network fetch. Production metadata/JWKS retrieval needs a configured HTTPS issuer, bounded caching, rate limits, controlled redirects and egress, and a last-known-good rotation strategy. - Only RS256 with
typ=JWTis modeled. A maintained library should be configured for the provider's exact asymmetric algorithm and token profile. - The clock is injected so time tests are deterministic. Deployed code needs a narrow documented skew and synchronized clocks.
- Exact matching is shown for a confidential web client's HTTPS redirects. Native loopback redirects have the port exception defined by RFC 8252 and are intentionally outside this matcher.
- The lab does not implement authorization-code redemption, nonce/state persistence, DPoP, mTLS, introspection, revocation, or refresh-token rotation.
- Neither raw tokens nor PKCE verifiers should be written to logs.