diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index f25627e29..b5a22ad7a 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -6,8 +6,10 @@ import { NetworkError, PermissionDeniedError, ServiceError, + TokenExpiredError, UnauthenticatedError, } from '../errors.js'; +import { readJwtExpMs } from '../auth/interceptors.js'; import { validateSecureUrl } from '../utils.js'; export type RewrapRequest = { @@ -21,6 +23,32 @@ export type RewrapResponseLegacy = { schemaVersion: string; }; +/** + * Classify a rewrap 401. When the `Authorization` header this SDK STAMPED on the request + * carries a JWT that was already expired (by local clock) as the request went out, the 401 + * is the well-known static-token-provider footgun — surfaced as {@link TokenExpiredError}, + * which has a clear recovery path (refresh the token and retry) and must not be conflated + * with an authorization denial. Anything else — an opaque token, an unreadable JWT, a + * still-valid `exp` — classifies as the generic {@link UnauthenticatedError}: an expired + * stamp is the ONLY trigger, so the specific error is zero-false-positive. + */ +function classifyRewrap401( + headers: Record | undefined, + url: string +): UnauthenticatedError { + const auth = headers?.['Authorization'] ?? headers?.['authorization']; + const match = typeof auth === 'string' ? auth.match(/^(?:Bearer|DPoP)\s+(.+)$/i) : null; + const expMs = match ? readJwtExpMs(match[1]) : undefined; + if (expMs !== undefined && expMs <= Date.now()) { + return new TokenExpiredError( + `401 for [${url}]; rewrap auth failure: the stamped access token was already EXPIRED — ` + + 'refresh it and retry (see refreshTokenProvider / clientCredentialsTokenProvider / ' + + 'externalJwtTokenProvider)' + ); + } + return new UnauthenticatedError(`401 for [${url}]; rewrap auth failure`); +} + /** * Get a rewrapped access key to the document, if possible * @param url Key access server rewrap endpoint @@ -66,7 +94,9 @@ export async function fetchWrappedKey( `400 for [${req.url}]: rewrap bad request [${await response.text()}]` ); case 401: - throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`); + // Distinguish the static-token-provider footgun (expired stamp -> TokenExpiredError, + // recoverable by refresh+retry) from a generic auth failure. See classifyRewrap401. + throw classifyRewrap401(req.headers, req.url); case 403: throw new PermissionDeniedError( `403 for [${req.url}]; rewrap permission denied: forbidden` diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..2fef9aea9 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -12,6 +12,57 @@ import { base64 } from '../encodings/index.js'; */ export type TokenProvider = () => Promise; +/** + * Read a JWT's `exp` claim as epoch-milliseconds, WITHOUT verifying the signature. + * Returns undefined for opaque (non-JWT) tokens or an unreadable payload — so the + * expiry footgun check below is skipped rather than firing a false positive. + */ +export function readJwtExpMs(token: string): number | undefined { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + try { + let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) b64 += '='; + const { exp } = JSON.parse(atob(b64)) as { exp?: number }; + return typeof exp === 'number' ? exp * 1000 : undefined; + } catch { + return undefined; + } +} + +/** + * A common footgun: passing a *static* token getter (e.g. `() => myToken`) to an interceptor. + * The interceptor faithfully stamps that token on every request, but a static getter never + * refreshes — so once the (typically short-lived) access token expires, every request goes out + * with a dead Bearer and the platform answers HTTP 401. Callers routinely mis-diagnose that 401 + * as an authorization / "access denied" failure and lose hours to it. + * + * A correctly-refreshing provider NEVER returns an already-expired token, so an expired token is + * an unambiguous, zero-false-positive signal of the footgun. This factory returns a guard that + * emits ONE actionable `console.warn` the first time it sees an expired JWT and then stays quiet + * (no log spam). It never throws and silently ignores opaque tokens whose expiry can't be read. + */ +function makeExpiredTokenWarner(interceptorName: string): (token: string) => void { + let warned = false; + return (token: string) => { + if (warned) return; + const expMs = readJwtExpMs(token); + if (expMs !== undefined && expMs <= Date.now()) { + warned = true; + // eslint-disable-next-line no-console + console.warn( + `[OpenTDF] ${interceptorName} received an already-EXPIRED access token. Your ` + + '`tokenProvider` is returning a token it never refreshes, so platform requests will ' + + 'fail with HTTP 401 — frequently mis-diagnosed as an authorization / "access denied" ' + + 'error. Fix: wrap a refreshing provider — refreshTokenProvider(), ' + + 'clientCredentialsTokenProvider(), or externalJwtTokenProvider() — or refresh inside ' + + 'your own tokenProvider so it never returns an expired token. ' + + 'Docs: https://github.com/opentdf/web-sdk#authentication' + ); + } + }; +} + /** * Options for creating a DPoP-aware auth interceptor. */ @@ -49,8 +100,10 @@ export type DPoPInterceptor = Interceptor & { * ``` */ export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor { + const warnIfExpired = makeExpiredTokenWarner('authTokenInterceptor'); return (next) => async (req) => { const token = await tokenProvider(); + warnIfExpired(token); req.header.set('Authorization', `Bearer ${token}`); return next(req); }; @@ -81,9 +134,11 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const dpopKeysPromise: Promise = options.dpopKeys ? Promise.resolve(options.dpopKeys) : cryptoService.generateSigningKeyPair(); + const warnIfExpired = makeExpiredTokenWarner('authTokenDPoPInterceptor'); const interceptor: Interceptor = (next) => async (req) => { const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]); + warnIfExpired(token); const url = new URL(req.url); const httpUri = `${url.origin}${url.pathname}`; diff --git a/lib/src/errors.ts b/lib/src/errors.ts index af46bb80e..1afc54323 100644 --- a/lib/src/errors.ts +++ b/lib/src/errors.ts @@ -100,6 +100,22 @@ export class UnauthenticatedError extends TdfError { override name = 'UnauthenticatedError'; } +/** + * Authentication failure (401) where the access token this SDK stamped on the request was + * ALREADY EXPIRED (by local clock) when the platform rejected it — the static-token-provider + * footgun surfaced as a typed error. Unlike a generic 401, and very unlike a + * `PermissionDeniedError`, this failure has a clear recovery path: obtain a fresh access + * token (e.g. via `refreshTokenProvider` / `clientCredentialsTokenProvider`) and retry. + * Callers surfacing access verdicts should treat it as "no decision — retry", never as an + * authorization DENY. + * + * Subclasses `UnauthenticatedError`, so existing `instanceof UnauthenticatedError` handling + * continues to match (non-breaking). + */ +export class TokenExpiredError extends UnauthenticatedError { + override name = 'TokenExpiredError'; +} + /** Authorization failure (403) */ export class PermissionDeniedError extends TdfError { override name = 'PermissionDeniedError'; diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index abdba1c86..9f93bb833 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -13,6 +13,7 @@ import { NetworkError, PermissionDeniedError, ServiceError, + TokenExpiredError, UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; @@ -113,10 +114,68 @@ describe('access-fetch.js', () => { expect.fail('Should have thrown'); } catch (e) { expect(e).to.be.instanceOf(UnauthenticatedError); + // an OPAQUE stamped token ('Bearer test-token') carries no readable expiry, so the + // 401 must stay the GENERIC auth failure — never the expired-token classification. + expect(e).to.not.be.instanceOf(TokenExpiredError); expect(e.message).to.equal(`401 for [${url}]; rewrap auth failure`); } }); + /** Build an unsigned JWT whose `exp` is `deltaSeconds` from now (negative = expired). */ + const makeJwt = (deltaSeconds: number): string => { + const b64url = (o: unknown): string => + btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const exp = Math.floor(Date.now() / 1000) + deltaSeconds; + return `${b64url({ alg: 'none' })}.${b64url({ exp })}.sig`; + }; + + /** An auth provider that stamps a specific Authorization header value. */ + const providerStamping = (authorization: string): AuthProvider => + ({ + withCreds: async (req: { headers?: Record }) => ({ + ...req, + headers: { ...req.headers, Authorization: authorization }, + }), + }) as unknown as AuthProvider; + + it('should throw TokenExpiredError when the 401 request was stamped with an EXPIRED JWT', async () => { + fetchStub.returns(createMockResponse('Auth failed', false, 401, 'Unauthorized')); + + try { + await fetchWrappedKey(url, requestBody, providerStamping(`Bearer ${makeJwt(-60)}`)); + expect.fail('Should have thrown'); + } catch (e) { + // the typed footgun signal — AND still an UnauthenticatedError, so existing + // instanceof handling keeps matching (non-breaking subclass). + expect(e).to.be.instanceOf(TokenExpiredError); + expect(e).to.be.instanceOf(UnauthenticatedError); + expect(e.message).to.contain('EXPIRED'); + } + }); + + it('should throw plain UnauthenticatedError when the 401 request carried a still-VALID JWT', async () => { + fetchStub.returns(createMockResponse('Auth failed', false, 401, 'Unauthorized')); + + try { + await fetchWrappedKey(url, requestBody, providerStamping(`Bearer ${makeJwt(300)}`)); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(UnauthenticatedError); + expect(e).to.not.be.instanceOf(TokenExpiredError); + } + }); + + it('should classify a DPoP-scheme expired stamp the same as Bearer', async () => { + fetchStub.returns(createMockResponse('Auth failed', false, 401, 'Unauthorized')); + + try { + await fetchWrappedKey(url, requestBody, providerStamping(`DPoP ${makeJwt(-60)}`)); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(TokenExpiredError); + } + }); + it('should throw PermissionDeniedError for a 403 Forbidden response', async () => { fetchStub.returns(createMockResponse('Forbidden', false, 403, 'Forbidden')); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..adf58c55e 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -26,6 +26,15 @@ async function captureHeaders( return headers; } +/** Build an unsigned JWT whose `exp` is `deltaSeconds` from now (negative = already expired). */ +function makeJwt(deltaSeconds: number): string { + const b64url = (o: unknown): string => + btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const header = { alg: 'RS256', typ: 'JWT' }; + const payload = { exp: Math.floor(Date.now() / 1000) + deltaSeconds }; + return `${b64url(header)}.${b64url(payload)}.unsigned`; +} + // --- authTokenInterceptor --- describe('authTokenInterceptor', () => { @@ -51,6 +60,63 @@ describe('authTokenInterceptor', () => { }); }); +// --- expired-token footgun warning --- + +describe('authTokenInterceptor expired-token warning', () => { + let warnings: string[]; + let origWarn: typeof console.warn; + + beforeEach(() => { + warnings = []; + origWarn = console.warn; + // eslint-disable-next-line no-console + console.warn = (...args: unknown[]) => { + warnings.push(args.map((a) => String(a)).join(' ')); + }; + }); + + afterEach(() => { + // eslint-disable-next-line no-console + console.warn = origWarn; + }); + + it('warns ONCE when the tokenProvider returns an already-expired JWT (and still stamps it)', async () => { + const expired = makeJwt(-60); + const interceptor = authTokenInterceptor(async () => expired); + + const headers = await captureHeaders(interceptor); + // Behavior is unchanged — the token is still stamped (non-breaking guard). + expect(headers.get('Authorization')).to.equal(`Bearer ${expired}`); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include('EXPIRED'); + expect(warnings[0]).to.include('refreshTokenProvider'); + + // A second request must NOT warn again (one-shot, no log spam). + await captureHeaders(interceptor); + expect(warnings).to.have.length(1); + }); + + it('does NOT warn for a valid (unexpired) JWT', async () => { + const interceptor = authTokenInterceptor(async () => makeJwt(300)); + await captureHeaders(interceptor); + expect(warnings).to.have.length(0); + }); + + it('does NOT warn for an opaque (non-JWT) token', async () => { + const interceptor = authTokenInterceptor(async () => 'opaque-token-abc'); + const headers = await captureHeaders(interceptor); + expect(headers.get('Authorization')).to.equal('Bearer opaque-token-abc'); + expect(warnings).to.have.length(0); + }); + + it('also warns for authTokenDPoPInterceptor on an expired token', async () => { + const interceptor = authTokenDPoPInterceptor({ tokenProvider: async () => makeJwt(-1) }); + await captureHeaders(interceptor); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include('authTokenDPoPInterceptor'); + }); +}); + // --- authTokenDPoPInterceptor --- describe('authTokenDPoPInterceptor', () => {