Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion lib/src/access/access-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, string> | undefined,
url: string
): UnauthenticatedError {
const auth = headers?.['Authorization'] ?? headers?.['authorization'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

HTTP headers are case-insensitive, but when represented as a plain JavaScript object (Record<string, string>), lookups are case-sensitive. Depending on the AuthProvider implementation or HTTP client/proxy, the Authorization header might be capitalized differently (e.g., AUTHORIZATION or authorization). Performing a case-insensitive lookup ensures robustness.

Suggested change
const auth = headers?.['Authorization'] ?? headers?.['authorization'];
const authKey = headers ? Object.keys(headers).find((k) => k.toLowerCase() === 'authorization') : undefined;
const auth = authKey ? headers[authKey] : undefined;

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
Expand Down Expand Up @@ -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`
Expand Down
55 changes: 55 additions & 0 deletions lib/src/auth/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,57 @@ import { base64 } from '../encodings/index.js';
*/
export type TokenProvider = () => Promise<string>;

/**
* 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 };
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using atob directly can fail to correctly decode UTF-8 characters in the JWT payload (e.g., non-ASCII characters in user claims), which can cause JSON.parse to throw an error and silently fail to read the expiration. Additionally, atob might not be globally defined in some environments (like older Node.js versions). Decoding the binary string using TextDecoder and providing a fallback to Buffer ensures cross-platform correctness and robust UTF-8 handling.

    let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
    while (b64.length % 4 !== 0) b64 += '=';
    const binString = typeof atob === 'function'
      ? atob(b64)
      : Buffer.from(b64, 'base64').toString('binary');
    const bytes = Uint8Array.from(binString, (c) => c.charCodeAt(0));
    const decoded = new TextDecoder().decode(bytes);
    const { exp } = JSON.parse(decoded) 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.
*/
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -81,9 +134,11 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI
const dpopKeysPromise: Promise<KeyPair> = 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}`;
Expand Down
16 changes: 16 additions & 0 deletions lib/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
59 changes: 59 additions & 0 deletions lib/tests/web/access/access-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
NetworkError,
PermissionDeniedError,
ServiceError,
TokenExpiredError,
UnauthenticatedError,
} from '../../../src/errors.js';
import { OriginAllowList } from '../../../src/access.js';
Expand Down Expand Up @@ -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<string, string> }) => ({
...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'));

Expand Down
66 changes: 66 additions & 0 deletions lib/tests/web/interceptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading