Skip to content

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

oauth-security.js JavaScript · 205 lines
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};

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 kid without 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=JWT is 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.