A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Security hardening & upstream parity (v5.0.0)

Issuer verification after token exchange prevents malicious auth servers
from issuing tokens for other users. Token responses are now validated
(DPoP type, atproto scope, DID sub claim). Auth server metadata is
validated against the AT Protocol spec (issuer match, HTTPS endpoints).

Also adds: iss parameter validation (RFC 9207), DPoP htu normalization
(RFC 9449), nonce caching, auto-retry on 401, refresh timeout, event
callbacks, JARM detection, HTTPS enforcement, distributed lock support,
and token revocation on refresh failure.

+1136 -123
+45
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [5.0.0] - 2026-02-15 9 + 10 + ### Breaking 11 + 12 + - **Token response validation**: `callback()` now validates the full token 13 + response from the auth server (access_token, token_type=DPoP, sub=did:*, 14 + scope contains atproto, expires_in > 0). Previously invalid responses 15 + would silently create broken sessions. 16 + - **Auth server metadata validation**: `discoverOAuthEndpointsFromAuthServer()` 17 + now returns an `issuer` field and validates metadata against the AT Protocol 18 + OAuth spec (issuer match, HTTPS endpoints, DPoP ES256 support). 19 + 20 + ### Added 21 + 22 + - **Issuer verification after token exchange** (critical security fix): After 23 + exchanging the authorization code, the client now resolves the DID's PDS to 24 + verify the auth server is authoritative for that identity. Prevents a 25 + malicious auth server from issuing tokens claiming to be another user. 26 + - **`iss` parameter validation** (RFC 9207): The callback now validates the 27 + `iss` query parameter when present, rejecting mismatched issuers. 28 + - **JARM detection**: The callback rejects JWT-encoded authorization responses 29 + (`response` parameter) with a clear error. 30 + - **DPoP `htu` normalization** (RFC 9449): Query parameters and fragments are 31 + now stripped from the `htu` claim in DPoP proofs. 32 + - **DPoP nonce caching**: Nonces are cached per-origin so the first request 33 + to a server that requires nonces doesn't need a retry. 34 + - **Auto-retry on 401**: Sessions now automatically refresh tokens and retry 35 + when a request returns 401 Unauthorized. 36 + - **Refresh timeout**: Token refresh operations now have a configurable timeout 37 + (default 30s) via `refreshTimeout` config option. 38 + - **Token revocation on refresh failure**: When a non-recoverable refresh 39 + error occurs, the old refresh token is revoked (best effort). 40 + - **Event callbacks**: `onSessionUpdated` and `onSessionDeleted` config 41 + options for reacting to session lifecycle events. 42 + - **Custom distributed locking**: `requestLock` config option for Redis-based 43 + or other distributed refresh token locking. 44 + - **HTTPS enforcement**: All discovered OAuth endpoints are validated to use 45 + HTTPS. 46 + - **New error types**: `MetadataValidationError`, `IssuerMismatchError`, 47 + `TokenValidationError` for precise error handling. 48 + - **Validation exports**: `validateAuthServerMetadata()` and 49 + `validateTokenResponse()` are exported for use by consumers. 50 + - **Upstream attribution**: LICENSE, NOTICE, and README now explicitly credit 51 + the Bluesky AT Protocol libraries. 52 + 8 53 ## [4.1.0] - 2026-02-15 9 54 10 55 ### Added
+5 -1
LICENSE
··· 2 2 3 3 Copyright (c) 2025 Tijs Teulings 4 4 5 + Portions inspired by @atproto/oauth-client and @atproto/oauth-client-node 6 + Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors 7 + Licensed under MIT and Apache 2.0 — https://github.com/bluesky-social/atproto 8 + 5 9 Permission is hereby granted, free of charge, to any person obtaining a copy 6 10 of this software and associated documentation files (the "Software"), to deal 7 11 in the Software without restriction, including without limitation the rights ··· 18 22 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 23 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 24 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 - SOFTWARE. 25 + SOFTWARE.
+10
NOTICE
··· 1 + This software incorporates patterns and specifications from: 2 + 3 + @atproto/oauth-client and @atproto/oauth-client-node 4 + https://github.com/bluesky-social/atproto 5 + Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors 6 + Licensed under MIT and Apache 2.0 7 + 8 + This is an independent implementation for Deno using Web Crypto APIs, 9 + not a direct fork or copy. It was built from the AT Protocol OAuth 10 + specification with architectural guidance from the official libraries.
+12 -5
README.md
··· 403 403 404 404 If this package helps your app development, consider [supporting on Ko-fi](https://ko-fi.com/tijsteulings). Your support helps maintain and improve this package. 405 405 406 - ## 🙏 Acknowledgments 406 + ## Acknowledgments 407 + 408 + This package implements the [AT Protocol OAuth specification](https://atproto.com/specs/oauth) 409 + for Deno environments using Web Crypto APIs. 410 + 411 + **Based on specifications and patterns from:** 412 + 413 + - [@atproto/oauth-client](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) 414 + and [@atproto/oauth-client-node](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node) 415 + (Copyright 2022-2025 Bluesky Social PBC, MIT and Apache 2.0) 416 + - [Bookhive OAuth implementation](https://github.com/nperez0111/bookhive) 407 417 408 - - Built to solve compatibility issues with `@atproto/oauth-client-node` in Deno 409 - - Inspired by the AT Protocol OAuth specification and reference implementations 410 - - Inspired by the Bookhive OAuth implementation: https://github.com/nperez0111/bookhive 411 - - Thanks to the Bluesky team for the AT Protocol ecosystem 418 + Thanks to the Bluesky team for the AT Protocol ecosystem. 412 419 413 420 ## See Also 414 421
+1 -1
deno.json
··· 1 1 { 2 2 "name": "@tijs/oauth-client-deno", 3 - "version": "4.1.2", 3 + "version": "5.0.0", 4 4 "description": "AT Protocol OAuth client for Deno - handle-focused alternative to @atproto/oauth-client-node with Web Crypto API compatibility", 5 5 "license": "MIT", 6 6 "repository": {
+6
mod.ts
··· 50 50 OAuthStorage, 51 51 } from "./src/types.ts"; 52 52 export * from "./src/errors.ts"; 53 + export { 54 + validateAuthServerMetadata, 55 + type ValidatedAuthServerMetadata, 56 + type ValidatedTokenResponse, 57 + validateTokenResponse, 58 + } from "./src/validation.ts";
+224 -83
src/client.ts
··· 11 11 AuthorizationError, 12 12 InvalidHandleError, 13 13 InvalidStateError, 14 + IssuerMismatchError, 14 15 NetworkError, 15 16 OAuthError, 16 17 RefreshTokenExpiredError, ··· 24 25 discoverOAuthEndpointsFromPDS, 25 26 resolveDidDocument, 26 27 } from "./resolvers.ts"; 28 + import { validateTokenResponse } from "./validation.ts"; 27 29 import { generateCodeChallenge, generateCodeVerifier } from "./pkce.ts"; 28 30 import { exchangeCodeForTokens, refreshTokens } from "./token-exchange.ts"; 29 31 import type { Logger } from "./logger.ts"; ··· 68 70 * ``` 69 71 */ 70 72 export class OAuthClient { 73 + private readonly config: OAuthClientConfig; 71 74 private readonly clientId: string; 72 75 private readonly redirectUri: string; 73 76 private readonly storage: OAuthStorage; 74 77 private readonly handleResolver: (handle: string) => Promise<{ did: string; pdsUrl: string }>; 75 78 private readonly logger: Logger; 79 + private readonly refreshTimeout: number; 76 80 77 81 /** 78 82 * Per-session lock manager to prevent concurrent restore/refresh operations. ··· 105 109 * ``` 106 110 */ 107 111 constructor(config: OAuthClientConfig) { 112 + this.config = config; 108 113 this.clientId = config.clientId; 109 114 this.redirectUri = config.redirectUri; 110 115 this.storage = config.storage; 111 116 this.logger = config.logger ?? new NoOpLogger(); 117 + this.refreshTimeout = config.refreshTimeout ?? 30_000; 112 118 113 119 // Create handle resolver - either custom or default with optional Slingshot URL 114 120 const resolver = config.handleResolver ?? createDefaultResolver(config.slingshotUrl); ··· 174 180 let pdsUrl: string; 175 181 let handle: string; 176 182 183 + let issuer: string; 184 + 177 185 if (isAuthServerUrl) { 178 186 // Authorization server URL provided directly — skip handle resolution 179 187 authServer = input.replace(/\/$/, ""); ··· 185 193 // Discover OAuth endpoints to verify this is a valid auth server 186 194 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(authServer); 187 195 authServer = this.extractAuthServer(oauthEndpoints.authorizationEndpoint); 188 - this.logger.debug("OAuth endpoints discovered", { authServer }); 196 + issuer = oauthEndpoints.issuer; 197 + this.logger.debug("OAuth endpoints discovered", { authServer, issuer }); 189 198 } else { 190 199 // Resolve handle to get user's PDS and DID 191 200 handle = input; ··· 199 208 // Discover OAuth endpoints from the PDS 200 209 const oauthEndpoints = await discoverOAuthEndpointsFromPDS(pdsUrl); 201 210 authServer = this.extractAuthServer(oauthEndpoints.authorizationEndpoint); 202 - this.logger.debug("OAuth endpoints discovered", { authServer }); 211 + issuer = oauthEndpoints.issuer; 212 + this.logger.debug("OAuth endpoints discovered", { authServer, issuer }); 203 213 } 204 214 205 215 // Generate PKCE parameters ··· 207 217 const codeChallenge = await generateCodeChallenge(codeVerifier); 208 218 const state = options?.state ?? crypto.randomUUID(); 209 219 210 - // Store PKCE data for callback 220 + // Store PKCE data for callback (includes issuer for verification) 211 221 await this.storage.set(`pkce:${state}`, { 212 222 codeVerifier, 213 223 authServer, 224 + issuer, 214 225 handle, 215 226 did, 216 227 pdsUrl, ··· 267 278 async callback( 268 279 params: URLSearchParams, 269 280 ): Promise<{ session: OAuthSession; state: string | null }> { 281 + // JARM detection — reject JWT-encoded authorization responses 282 + const responseJwt = params.get("response"); 283 + if (responseJwt) { 284 + throw new OAuthError( 285 + "JWT Secured Authorization Response Mode (JARM) is not supported. " + 286 + "The authorization server returned a JWT response.", 287 + ); 288 + } 289 + 270 290 const error = params.get("error"); 271 291 if (error) { 272 292 this.logger.error("Authorization callback error", { ··· 290 310 const pkceData = await this.storage.get<{ 291 311 codeVerifier: string; 292 312 authServer: string; 313 + issuer: string; 293 314 handle: string; 294 315 did: string; 295 316 pdsUrl: string; ··· 300 321 throw new InvalidStateError(); 301 322 } 302 323 324 + // Validate iss parameter (RFC 9207) when present 325 + const iss = params.get("iss"); 326 + if (iss && iss !== pkceData.issuer) { 327 + this.logger.error("Issuer mismatch in callback iss parameter", { 328 + expected: pkceData.issuer, 329 + actual: iss, 330 + }); 331 + await this.storage.delete(`pkce:${state}`); 332 + throw new IssuerMismatchError(pkceData.issuer, iss); 333 + } 334 + 303 335 try { 304 336 // Generate DPoP keys for token exchange 305 337 this.logger.debug("Generating DPoP keys"); 306 338 const dpopKeys = await generateDPoPKeyPair(); 307 339 308 340 // Exchange authorization code for tokens 309 - const tokens = await exchangeCodeForTokens( 341 + const rawTokens = await exchangeCodeForTokens( 310 342 pkceData.authServer, 311 343 code, 312 344 pkceData.codeVerifier, ··· 316 348 this.logger, 317 349 ); 318 350 319 - // Resolve DID, handle, and PDS from token response when not available 320 - // (auth server URL flow skips handle resolution, so these come from `sub`) 351 + // Validate token response 352 + const validatedTokens = validateTokenResponse(rawTokens); 353 + 354 + // Resolve DID, handle, and PDS from token response 321 355 let { did, handle, pdsUrl } = pkceData; 322 - if (!did && tokens.sub) { 323 - did = tokens.sub; 356 + const tokenDid = validatedTokens.sub; 357 + 358 + if (!did) { 359 + // Auth server URL flow — populate from token sub claim 360 + did = tokenDid; 324 361 this.logger.debug("Using DID from token response sub claim", { did }); 325 362 const resolved = await resolveDidDocument(did); 326 363 handle = resolved.handle; 327 364 pdsUrl = resolved.pdsUrl; 328 365 this.logger.debug("Resolved DID document", { handle, pdsUrl }); 329 366 } 367 + 368 + // CRITICAL: Verify the auth server is authoritative for this DID 369 + // Prevents a malicious auth server from claiming to be another user 370 + await this.verifyIssuer(tokenDid, pkceData.authServer, pkceData.issuer, pdsUrl); 330 371 331 372 // Create session 332 373 const sessionData: SessionData = { 333 374 did, 334 375 handle, 335 376 pdsUrl, 336 - accessToken: tokens.access_token, 337 - refreshToken: tokens.refresh_token ?? "", 377 + accessToken: validatedTokens.access_token, 378 + refreshToken: validatedTokens.refresh_token ?? "", 338 379 dpopPrivateKeyJWK: dpopKeys.privateKeyJWK, 339 380 dpopPublicKeyJWK: dpopKeys.publicKeyJWK, 340 - tokenExpiresAt: Date.now() + (tokens.expires_in * 1000), 381 + tokenExpiresAt: Date.now() + (validatedTokens.expires_in * 1000), 341 382 }; 342 383 343 384 const session = new Session(sessionData); 385 + 386 + // Attach refresh callback for auto-retry on 401 387 + this.attachRefreshCallback(session, did); 344 388 345 389 // Clean up PKCE data 346 390 await this.storage.delete(`pkce:${state}`); 347 391 348 - this.logger.info("Authorization callback completed", { did: pkceData.did }); 349 - return { session: session, state: params.get("state") }; 392 + this.logger.info("Authorization callback completed", { did }); 393 + 394 + // Emit session event 395 + this.config.onSessionUpdated?.(did, session); 396 + 397 + return { session, state: params.get("state") }; 350 398 } catch (error) { 351 399 // Clean up PKCE data even on error 352 400 await this.storage.delete(`pkce:${state}`); ··· 415 463 } 416 464 417 465 const session = Session.fromJSON(sessionData); 466 + 467 + // Attach refresh callback for auto-retry on 401 468 + this.attachRefreshCallback(session, sessionId); 418 469 419 470 // Auto-refresh if needed 420 471 if (session.isExpired) { ··· 540 591 refresh(session: Session): Promise<Session> { 541 592 const did = session.did; 542 593 594 + // Use custom lock if provided 595 + if (this.config.requestLock) { 596 + return this.config.requestLock( 597 + `refresh:${did}`, 598 + () => this.performRefresh(session, did), 599 + ); 600 + } 601 + 543 602 // Check if another request is already refreshing this session 544 603 const existingLock = this.refreshLocks.get(did); 545 604 if (existingLock) { ··· 547 606 return existingLock; 548 607 } 549 608 550 - // Create a new refresh operation 609 + // Create a new refresh operation with in-memory lock 551 610 const refreshPromise = (async () => { 552 - this.logger.info("Refreshing tokens", { did }); 611 + try { 612 + return await this.performRefresh(session, did); 613 + } finally { 614 + this.refreshLocks.delete(did); 615 + } 616 + })(); 553 617 554 - try { 555 - // Discover OAuth endpoints from PDS 556 - const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 557 - this.logger.debug("Token endpoint discovered", { 558 - tokenEndpoint: oauthEndpoints.tokenEndpoint, 559 - }); 618 + this.refreshLocks.set(did, refreshPromise); 619 + return refreshPromise; 620 + } 560 621 561 - const refreshedTokens = await refreshTokens( 562 - oauthEndpoints.tokenEndpoint, 563 - session.refreshToken, 564 - this.clientId, 565 - session.toJSON().dpopPrivateKeyJWK, 566 - session.toJSON().dpopPublicKeyJWK, 567 - this.logger, 568 - ); 622 + private async performRefresh(session: Session, did: string): Promise<Session> { 623 + this.logger.info("Refreshing tokens", { did }); 569 624 570 - // Update session with new tokens 571 - session.updateTokens(refreshedTokens); 572 - this.logger.info("Token refresh successful", { did }); 573 - return session; 574 - } catch (error) { 575 - this.logger.error("Token refresh failed", { did, error }); 625 + try { 626 + const oauthEndpoints = await discoverOAuthEndpointsFromPDS(session.pdsUrl); 627 + this.logger.debug("Token endpoint discovered", { 628 + tokenEndpoint: oauthEndpoints.tokenEndpoint, 629 + }); 576 630 577 - // Check for token replay error (concurrent refresh in another isolate) 578 - if (this.isTokenReplayedError(error)) { 579 - this.logger.info("Token replay detected, fetching updated session from storage", { did }); 631 + const refreshedTokens = await refreshTokens( 632 + oauthEndpoints.tokenEndpoint, 633 + session.refreshToken, 634 + this.clientId, 635 + session.toJSON().dpopPrivateKeyJWK, 636 + session.toJSON().dpopPublicKeyJWK, 637 + this.logger, 638 + this.refreshTimeout, 639 + ); 580 640 581 - // Wait briefly for the other process to save 582 - await this.sleep(200); 641 + session.updateTokens(refreshedTokens); 642 + this.logger.info("Token refresh successful", { did }); 583 643 584 - // Re-read session from storage (the other process should have saved new tokens) 585 - const updatedSessionData = await this.storage.get<SessionData>(`session:${did}`); 586 - if (updatedSessionData) { 587 - const updatedSession = Session.fromJSON(updatedSessionData); 588 - if (!updatedSession.isExpired) { 589 - this.logger.info("Retrieved refreshed session from storage after replay detection", { 590 - did, 591 - }); 592 - return updatedSession; 593 - } 594 - } 644 + this.config.onSessionUpdated?.(did, session); 645 + return session; 646 + } catch (error) { 647 + this.logger.error("Token refresh failed", { did, error }); 595 648 596 - // Could not recover - throw the original error 597 - this.logger.error("Could not recover from token replay - no valid session in storage", { 598 - did, 599 - }); 600 - } 649 + // Check for token replay error (concurrent refresh in another isolate) 650 + if (this.isTokenReplayedError(error)) { 651 + this.logger.info("Token replay detected, fetching updated session from storage", { did }); 652 + await this.sleep(200); 601 653 602 - // Classify the error based on type 603 - if (error instanceof TokenExchangeError) { 604 - // Already a TokenExchangeError, check for specific grant errors 605 - if (error.errorCode === "invalid_grant") { 606 - throw new RefreshTokenExpiredError(error); 654 + const updatedSessionData = await this.storage.get<SessionData>(`session:${did}`); 655 + if (updatedSessionData) { 656 + const updatedSession = Session.fromJSON(updatedSessionData); 657 + if (!updatedSession.isExpired) { 658 + this.logger.info("Retrieved refreshed session from storage after replay detection", { 659 + did, 660 + }); 661 + return updatedSession; 607 662 } 608 - throw error; 609 663 } 610 664 611 - // Check for network-related errors 612 - if (error instanceof Error) { 613 - const errorMessage = error.message.toLowerCase(); 614 - if ( 615 - errorMessage.includes("network") || 616 - errorMessage.includes("timeout") || 617 - errorMessage.includes("connection") || 618 - errorMessage.includes("fetch") 619 - ) { 620 - throw new NetworkError("Failed to reach token endpoint", error); 621 - } 622 - } 665 + this.logger.error("Could not recover from token replay - no valid session in storage", { 666 + did, 667 + }); 668 + } 623 669 624 - // Default to generic token exchange error 625 - throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 626 - } finally { 627 - // Always cleanup the lock when done 628 - this.refreshLocks.delete(did); 670 + // Best-effort revocation on non-recoverable, non-network errors 671 + if (!this.isTokenReplayedError(error) && !this.isNetworkError(error)) { 672 + this.revokeTokenBestEffort(session.pdsUrl, session.refreshToken); 629 673 } 630 - })(); 631 674 632 - // Store the promise so concurrent requests can wait for it 633 - this.refreshLocks.set(did, refreshPromise); 675 + if (error instanceof TokenExchangeError) { 676 + if (error.errorCode === "invalid_grant") { 677 + throw new RefreshTokenExpiredError(error); 678 + } 679 + throw error; 680 + } 634 681 635 - return refreshPromise; 682 + if (this.isNetworkError(error)) { 683 + throw new NetworkError("Failed to reach token endpoint", error as Error); 684 + } 685 + 686 + throw new TokenExchangeError("Token refresh failed", undefined, error as Error); 687 + } 636 688 } 637 689 638 690 /** ··· 692 744 // Always clean up storage 693 745 await this.storage.delete(`session:${sessionId}`); 694 746 this.logger.info("Session signed out", { sessionId }); 747 + 748 + // Emit session deleted event 749 + this.config.onSessionDeleted?.(sessionId); 695 750 } 696 751 } 697 752 698 753 // Private helper methods 699 754 755 + /** 756 + * Verify the authorization server is authoritative for the given DID. 757 + * 758 + * Resolves the DID document to find the user's PDS, then discovers the 759 + * auth server from that PDS and compares it to the auth server that 760 + * issued the tokens. This prevents a malicious auth server from issuing 761 + * tokens claiming to be a different user. 762 + */ 763 + private async verifyIssuer( 764 + did: string, 765 + authServer: string, 766 + issuer: string, 767 + knownPdsUrl?: string, 768 + ): Promise<void> { 769 + try { 770 + // Get the PDS for this DID (use known PDS if available to save a lookup) 771 + const pdsUrl = knownPdsUrl || (await resolveDidDocument(did)).pdsUrl; 772 + 773 + // Discover the expected auth server from the DID's PDS 774 + const expectedEndpoints = await discoverOAuthEndpointsFromPDS(pdsUrl); 775 + const expectedIssuer = expectedEndpoints.issuer; 776 + 777 + if (expectedIssuer !== issuer) { 778 + this.logger.error("Issuer verification failed", { 779 + did, 780 + expectedIssuer, 781 + actualIssuer: issuer, 782 + authServer, 783 + }); 784 + throw new IssuerMismatchError(expectedIssuer, issuer); 785 + } 786 + 787 + this.logger.debug("Issuer verification passed", { did, issuer }); 788 + } catch (error) { 789 + if (error instanceof IssuerMismatchError) throw error; 790 + // Log but don't block on verification failures (network issues etc.) 791 + // The token exchange already succeeded with PKCE protection 792 + this.logger.warn("Issuer verification could not be completed", { 793 + did, 794 + error, 795 + }); 796 + } 797 + } 798 + 799 + /** 800 + * Attach a refresh callback to a session for automatic 401 retry. 801 + */ 802 + private attachRefreshCallback(session: Session, sessionId: string): void { 803 + session.setRefreshCallback(async () => { 804 + const refreshed = await this.refresh(session); 805 + await this.store(sessionId, refreshed); 806 + }); 807 + } 808 + 700 809 private extractAuthServer(authorizationEndpoint: string): string { 701 810 return authorizationEndpoint.replace(/\/oauth\/authorize$/, ""); 702 811 } ··· 715 824 return message.includes("replayed") || description.includes("replayed"); 716 825 } 717 826 return false; 827 + } 828 + 829 + private isNetworkError(error: unknown): boolean { 830 + if (error instanceof NetworkError) return true; 831 + if (error instanceof Error) { 832 + const msg = error.message.toLowerCase(); 833 + return msg.includes("network") || msg.includes("timeout") || 834 + msg.includes("connection") || msg.includes("fetch"); 835 + } 836 + return false; 837 + } 838 + 839 + /** 840 + * Best-effort token revocation — fire and forget. 841 + */ 842 + private revokeTokenBestEffort(pdsUrl: string, token: string): void { 843 + discoverOAuthEndpointsFromPDS(pdsUrl).then((endpoints) => { 844 + if (endpoints.revocationEndpoint) { 845 + fetch(endpoints.revocationEndpoint, { 846 + method: "POST", 847 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 848 + body: new URLSearchParams({ 849 + token, 850 + client_id: this.clientId, 851 + }), 852 + }).catch(() => { 853 + // Intentionally ignored — best effort 854 + }); 855 + } 856 + }).catch(() => { 857 + // Intentionally ignored — best effort 858 + }); 718 859 } 719 860 720 861 /**
+30 -2
src/dpop.ts
··· 6 6 import { exportJWK, SignJWT } from "@panva/jose"; 7 7 import { DPoPError } from "./errors.ts"; 8 8 9 + /** Module-level nonce cache: maps origin to latest DPoP nonce */ 10 + const nonceCache = new Map<string, string>(); 11 + 12 + /** Get cached nonce for a URL's origin */ 13 + export function getCachedNonce(url: string): string | undefined { 14 + return nonceCache.get(new URL(url).origin); 15 + } 16 + 17 + /** Update nonce cache from a response's DPoP-Nonce header */ 18 + export function updateNonceCache(url: string, response: Response): void { 19 + const nonce = response.headers.get("DPoP-Nonce"); 20 + if (nonce) { 21 + nonceCache.set(new URL(url).origin, nonce); 22 + } 23 + } 24 + 9 25 export interface DPoPKeyPair { 10 26 privateKey: CryptoKey; 11 27 publicKey: CryptoKey; ··· 54 70 nonce?: string, 55 71 ): Promise<string> { 56 72 try { 73 + // Normalize htu per RFC 9449: strip query and fragment 74 + const htuUrl = new URL(url); 75 + const htu = `${htuUrl.origin}${htuUrl.pathname}`; 76 + 57 77 // Create DPoP JWT payload 58 78 const payload: Record<string, unknown> = { 59 79 jti: crypto.randomUUID(), 60 80 htm: method, 61 - htu: url, 81 + htu, 62 82 iat: Math.floor(Date.now() / 1000), 63 83 exp: Math.floor(Date.now() / 1000) + (5 * 60), // Expires in 5 minutes 64 84 }; ··· 147 167 headers: HeadersInit = {}, 148 168 ): Promise<Response> { 149 169 try { 150 - // Generate initial DPoP proof 170 + // Check nonce cache for this origin 171 + const cachedNonce = getCachedNonce(url); 172 + 173 + // Generate initial DPoP proof (with cached nonce if available) 151 174 let dpopProof = await generateDPoPProof( 152 175 method, 153 176 url, 154 177 privateKey, 155 178 publicKeyJWK, 156 179 accessToken, 180 + cachedNonce, 157 181 ); 158 182 159 183 const requestHeaders: HeadersInit = { ··· 172 196 } 173 197 174 198 let response = await fetch(url, fetchOptions); 199 + 200 + // Always update nonce cache from response 201 + updateNonceCache(url, response); 175 202 176 203 // Handle DPoP nonce challenge 177 204 if (response.status === 401) { ··· 198 225 } 199 226 200 227 response = await fetch(url, retryOptions); 228 + updateNonceCache(url, response); 201 229 } 202 230 } 203 231
+45
src/errors.ts
··· 453 453 this.name = "NetworkError"; 454 454 } 455 455 } 456 + 457 + /** 458 + * Thrown when authorization server metadata fails validation. 459 + * 460 + * This error occurs when the metadata from an OAuth authorization server 461 + * is missing required fields, has invalid values, or doesn't meet 462 + * AT Protocol requirements. 463 + */ 464 + export class MetadataValidationError extends OAuthError { 465 + constructor(message: string, cause?: Error) { 466 + super(`Invalid auth server metadata: ${message}`, cause); 467 + this.name = "MetadataValidationError"; 468 + } 469 + } 470 + 471 + /** 472 + * Thrown when the issuer in a token response doesn't match the expected 473 + * authorization server. This is a critical security check that prevents 474 + * a malicious auth server from issuing tokens for a different user. 475 + */ 476 + export class IssuerMismatchError extends OAuthError { 477 + constructor( 478 + public readonly expected: string, 479 + public readonly actual: string, 480 + ) { 481 + super( 482 + `Issuer mismatch: expected "${expected}" but got "${actual}". ` + 483 + `The authorization server is not authoritative for this identity.`, 484 + ); 485 + this.name = "IssuerMismatchError"; 486 + } 487 + } 488 + 489 + /** 490 + * Thrown when a token response fails validation. 491 + * 492 + * This error occurs when the token response from the authorization server 493 + * is missing required fields or contains invalid values. 494 + */ 495 + export class TokenValidationError extends TokenExchangeError { 496 + constructor(message: string, cause?: Error) { 497 + super(`Invalid token response: ${message}`, undefined, cause); 498 + this.name = "TokenValidationError"; 499 + } 500 + }
+15 -11
src/resolvers.ts
··· 5 5 6 6 import { AuthServerDiscoveryError, HandleResolutionError, PDSDiscoveryError } from "./errors.ts"; 7 7 import type { HandleResolver } from "./types.ts"; 8 + import { requireHttpsUrl, validateAuthServerMetadata } from "./validation.ts"; 8 9 9 10 /** 10 11 * Slingshot-based handle resolver for AT Protocol. ··· 389 390 390 391 // The authorization_servers field contains potential authentication servers 391 392 if (metadata.authorization_servers && metadata.authorization_servers.length > 0) { 392 - // Use the first authorization server 393 - return metadata.authorization_servers[0]; 393 + const authServer = metadata.authorization_servers[0]; 394 + requireHttpsUrl(authServer, "authorization_server"); 395 + return authServer; 394 396 } 395 397 396 398 // Fallback: assume PDS is the auth server ··· 422 424 export async function discoverOAuthEndpointsFromAuthServer( 423 425 authServerUrl: string, 424 426 ): Promise<{ 427 + issuer: string; 425 428 authorizationEndpoint: string; 426 429 tokenEndpoint: string; 427 - revocationEndpoint?: string; 430 + revocationEndpoint?: string | undefined; 428 431 }> { 429 432 try { 430 433 const response = await fetch( ··· 435 438 throw new Error(`OAuth discovery failed: ${response.status}`); 436 439 } 437 440 438 - const endpoints = await response.json(); 441 + const rawMetadata = await response.json(); 439 442 440 - if (!endpoints.authorization_endpoint || !endpoints.token_endpoint) { 441 - throw new Error("Missing required OAuth endpoints in discovery document"); 442 - } 443 + // Validate metadata including issuer match and HTTPS enforcement 444 + const metadata = validateAuthServerMetadata(rawMetadata, authServerUrl); 443 445 444 446 return { 445 - authorizationEndpoint: endpoints.authorization_endpoint, 446 - tokenEndpoint: endpoints.token_endpoint, 447 - revocationEndpoint: endpoints.revocation_endpoint, 447 + issuer: metadata.issuer, 448 + authorizationEndpoint: metadata.authorization_endpoint, 449 + tokenEndpoint: metadata.token_endpoint, 450 + revocationEndpoint: metadata.revocation_endpoint, 448 451 }; 449 452 } catch (error) { 450 453 throw new AuthServerDiscoveryError(authServerUrl, error as Error); ··· 471 474 export async function discoverOAuthEndpointsFromPDS( 472 475 pdsUrl: string, 473 476 ): Promise<{ 477 + issuer: string; 474 478 authorizationEndpoint: string; 475 479 tokenEndpoint: string; 476 - revocationEndpoint?: string; 480 + revocationEndpoint?: string | undefined; 477 481 }> { 478 482 try { 479 483 // Step 1: Try to discover authentication server from PDS
+33 -1
src/session.ts
··· 46 46 * ``` 47 47 */ 48 48 export class Session implements OAuthSession { 49 + private refreshCallback?: () => Promise<void>; 50 + 49 51 constructor(private data: SessionData) {} 52 + 53 + /** 54 + * Set a callback for automatic token refresh on 401 responses. 55 + * The callback should refresh the session tokens and update storage. 56 + */ 57 + setRefreshCallback(fn: () => Promise<void>): void { 58 + this.refreshCallback = fn; 59 + } 50 60 51 61 /** 52 62 * User's DID (Decentralized Identifier) ··· 162 172 this.data.dpopPrivateKeyJWK, 163 173 ); 164 174 165 - return await makeDPoPRequest( 175 + let response = await makeDPoPRequest( 166 176 method, 167 177 url, 168 178 this.data.accessToken, ··· 171 181 options?.body as string, 172 182 options?.headers, 173 183 ); 184 + 185 + // Auto-retry on 401 if refresh callback is available 186 + if (response.status === 401 && this.refreshCallback) { 187 + await this.refreshCallback(); 188 + 189 + // Re-import key (may have changed after refresh) 190 + const refreshedKey = await importPrivateKeyFromJWK( 191 + this.data.dpopPrivateKeyJWK, 192 + ); 193 + 194 + response = await makeDPoPRequest( 195 + method, 196 + url, 197 + this.data.accessToken, 198 + refreshedKey, 199 + this.data.dpopPublicKeyJWK, 200 + options?.body as string, 201 + options?.headers, 202 + ); 203 + } 204 + 205 + return response; 174 206 } catch (error) { 175 207 throw new SessionError( 176 208 "Failed to make authenticated request",
+35 -18
src/token-exchange.ts
··· 3 3 * @module 4 4 */ 5 5 6 - import { generateDPoPProof, importPrivateKeyFromJWK } from "./dpop.ts"; 6 + import { 7 + generateDPoPProof, 8 + getCachedNonce, 9 + importPrivateKeyFromJWK, 10 + updateNonceCache, 11 + } from "./dpop.ts"; 7 12 import { TokenExchangeError } from "./errors.ts"; 8 13 import type { Logger } from "./logger.ts"; 9 14 ··· 42 47 publicKeyJWK: JsonWebKey, 43 48 accessToken: string | undefined, 44 49 logger: Logger, 50 + timeoutMs?: number, 45 51 ): Promise<Response> { 46 - // Create initial DPoP proof 52 + // Check nonce cache for this origin 53 + const cachedNonce = getCachedNonce(tokenUrl); 54 + 55 + // Create initial DPoP proof (with cached nonce if available) 47 56 let dpopProof = await generateDPoPProof( 48 57 "POST", 49 58 tokenUrl, 50 59 privateKey, 51 60 publicKeyJWK, 52 61 accessToken, 62 + cachedNonce, 53 63 ); 54 64 55 65 logger.debug("Making token request with DPoP proof", { tokenUrl }); 56 66 57 - let response = await fetch(tokenUrl, { 58 - method: "POST", 59 - headers: { 60 - "Content-Type": "application/x-www-form-urlencoded", 61 - "DPoP": dpopProof, 62 - }, 63 - body, 64 - }); 67 + const fetchOptions = (dpop: string): RequestInit => { 68 + const opts: RequestInit = { 69 + method: "POST", 70 + headers: { 71 + "Content-Type": "application/x-www-form-urlencoded", 72 + "DPoP": dpop, 73 + }, 74 + body, 75 + }; 76 + if (timeoutMs) { 77 + const controller = new AbortController(); 78 + setTimeout(() => controller.abort(), timeoutMs); 79 + opts.signal = controller.signal; 80 + } 81 + return opts; 82 + }; 83 + 84 + let response = await fetch(tokenUrl, fetchOptions(dpopProof)); 85 + updateNonceCache(tokenUrl, response); 65 86 66 87 // Handle DPoP nonce requirement - AT Protocol uses 400 status 67 88 if (!response.ok && response.status === 400) { ··· 79 100 nonce, 80 101 ); 81 102 82 - response = await fetch(tokenUrl, { 83 - method: "POST", 84 - headers: { 85 - "Content-Type": "application/x-www-form-urlencoded", 86 - "DPoP": dpopProof, 87 - }, 88 - body, 89 - }); 103 + response = await fetch(tokenUrl, fetchOptions(dpopProof)); 104 + updateNonceCache(tokenUrl, response); 90 105 } 91 106 } 92 107 ··· 211 226 privateKeyJWK: JsonWebKey, 212 227 publicKeyJWK: JsonWebKey, 213 228 logger: Logger, 229 + timeoutMs?: number, 214 230 ): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> { 215 231 try { 216 232 logger.info("Refreshing access token", { tokenEndpoint }); ··· 231 247 publicKeyJWK, 232 248 undefined, 233 249 logger, 250 + timeoutMs, 234 251 ); 235 252 236 253 if (!response.ok) {
+22
src/types.ts
··· 107 107 * Implement the Logger interface to capture client logging output 108 108 */ 109 109 logger?: Logger; 110 + 111 + /** 112 + * Timeout for refresh token operations in milliseconds (default: 30000). 113 + */ 114 + refreshTimeout?: number; 115 + 116 + /** 117 + * Called after a session is updated (e.g., after token refresh). 118 + */ 119 + onSessionUpdated?: (sessionId: string, session: OAuthSession) => void; 120 + 121 + /** 122 + * Called after a session is deleted (e.g., after sign-out). 123 + */ 124 + onSessionDeleted?: (sessionId: string) => void; 125 + 126 + /** 127 + * Custom lock function for distributed refresh token locking. 128 + * Default uses in-memory Map locks (works for single-instance and Deno Deploy isolates). 129 + * Provide a custom implementation for distributed locking (e.g., Redis). 130 + */ 131 + requestLock?: <T>(key: string, fn: () => Promise<T>) => Promise<T>; 110 132 } 111 133 112 134 /**
+222
src/validation.ts
··· 1 + /** 2 + * @fileoverview Validation utilities for OAuth metadata and token responses 3 + * @module 4 + */ 5 + 6 + import { MetadataValidationError, TokenValidationError } from "./errors.ts"; 7 + 8 + /** 9 + * Validated authorization server metadata with required fields guaranteed present. 10 + */ 11 + export interface ValidatedAuthServerMetadata { 12 + issuer: string; 13 + authorization_endpoint: string; 14 + token_endpoint: string; 15 + pushed_authorization_request_endpoint?: string | undefined; 16 + revocation_endpoint?: string | undefined; 17 + dpop_signing_alg_values_supported?: string[] | undefined; 18 + } 19 + 20 + /** 21 + * Validated token response with required fields guaranteed present. 22 + */ 23 + export interface ValidatedTokenResponse { 24 + access_token: string; 25 + token_type: string; 26 + scope: string; 27 + sub: string; 28 + expires_in: number; 29 + refresh_token?: string | undefined; 30 + } 31 + 32 + /** 33 + * Validate that a URL uses the HTTPS scheme. 34 + * 35 + * @param url - URL string to validate 36 + * @param label - Human-readable label for error messages 37 + * @throws {MetadataValidationError} When URL is not HTTPS 38 + */ 39 + export function requireHttpsUrl(url: string, label: string): void { 40 + try { 41 + const parsed = new URL(url); 42 + if (parsed.protocol !== "https:") { 43 + throw new MetadataValidationError( 44 + `${label} must use HTTPS, got ${parsed.protocol} (${url})`, 45 + ); 46 + } 47 + } catch (error) { 48 + if (error instanceof MetadataValidationError) throw error; 49 + throw new MetadataValidationError(`${label} is not a valid URL: ${url}`); 50 + } 51 + } 52 + 53 + /** 54 + * Validate authorization server metadata per the AT Protocol OAuth spec. 55 + * 56 + * Checks: 57 + * - Response is an object with required fields 58 + * - `issuer` matches the expected URL (origin comparison) 59 + * - `authorization_endpoint` and `token_endpoint` are present and HTTPS 60 + * - `dpop_signing_alg_values_supported` includes "ES256" (if present) 61 + * 62 + * @param metadata - Raw metadata response from the auth server 63 + * @param expectedIssuer - The URL the metadata was fetched from 64 + * @returns Validated metadata with typed fields 65 + * @throws {MetadataValidationError} When metadata is invalid 66 + */ 67 + export function validateAuthServerMetadata( 68 + metadata: unknown, 69 + expectedIssuer: string, 70 + ): ValidatedAuthServerMetadata { 71 + if (!metadata || typeof metadata !== "object") { 72 + throw new MetadataValidationError("metadata is not an object"); 73 + } 74 + 75 + const md = metadata as Record<string, unknown>; 76 + 77 + // Validate issuer 78 + if (typeof md.issuer !== "string" || !md.issuer) { 79 + throw new MetadataValidationError("missing or invalid 'issuer' field"); 80 + } 81 + 82 + // Issuer must match the expected URL (origin comparison) 83 + const issuerOrigin = new URL(md.issuer).origin; 84 + const expectedOrigin = new URL(expectedIssuer).origin; 85 + if (issuerOrigin !== expectedOrigin) { 86 + throw new MetadataValidationError( 87 + `issuer origin "${issuerOrigin}" does not match expected "${expectedOrigin}"`, 88 + ); 89 + } 90 + 91 + // Validate required endpoints 92 + if (typeof md.authorization_endpoint !== "string" || !md.authorization_endpoint) { 93 + throw new MetadataValidationError("missing 'authorization_endpoint'"); 94 + } 95 + requireHttpsUrl(md.authorization_endpoint, "authorization_endpoint"); 96 + 97 + if (typeof md.token_endpoint !== "string" || !md.token_endpoint) { 98 + throw new MetadataValidationError("missing 'token_endpoint'"); 99 + } 100 + requireHttpsUrl(md.token_endpoint, "token_endpoint"); 101 + 102 + // Validate optional endpoints that must be HTTPS if present 103 + if (md.pushed_authorization_request_endpoint) { 104 + if (typeof md.pushed_authorization_request_endpoint !== "string") { 105 + throw new MetadataValidationError( 106 + "invalid 'pushed_authorization_request_endpoint'", 107 + ); 108 + } 109 + requireHttpsUrl( 110 + md.pushed_authorization_request_endpoint, 111 + "pushed_authorization_request_endpoint", 112 + ); 113 + } 114 + 115 + if (md.revocation_endpoint) { 116 + if (typeof md.revocation_endpoint !== "string") { 117 + throw new MetadataValidationError("invalid 'revocation_endpoint'"); 118 + } 119 + requireHttpsUrl(md.revocation_endpoint, "revocation_endpoint"); 120 + } 121 + 122 + // Validate DPoP signing algorithms if specified 123 + if (md.dpop_signing_alg_values_supported !== undefined) { 124 + if (!Array.isArray(md.dpop_signing_alg_values_supported)) { 125 + throw new MetadataValidationError( 126 + "'dpop_signing_alg_values_supported' must be an array", 127 + ); 128 + } 129 + if (!md.dpop_signing_alg_values_supported.includes("ES256")) { 130 + throw new MetadataValidationError( 131 + "server does not support ES256 for DPoP (required by AT Protocol)", 132 + ); 133 + } 134 + } 135 + 136 + return { 137 + issuer: md.issuer, 138 + authorization_endpoint: md.authorization_endpoint, 139 + token_endpoint: md.token_endpoint, 140 + pushed_authorization_request_endpoint: md 141 + .pushed_authorization_request_endpoint as string | undefined, 142 + revocation_endpoint: md.revocation_endpoint as string | undefined, 143 + dpop_signing_alg_values_supported: md.dpop_signing_alg_values_supported as 144 + | string[] 145 + | undefined, 146 + }; 147 + } 148 + 149 + /** 150 + * Validate a token response from the authorization server. 151 + * 152 + * Checks: 153 + * - `access_token` is a non-empty string 154 + * - `token_type` is "DPoP" (case-insensitive) 155 + * - `scope` exists and contains "atproto" 156 + * - `sub` is present and starts with "did:" 157 + * - `expires_in` is a positive number 158 + * - `refresh_token` is a string if present 159 + * 160 + * @param response - Raw token response JSON 161 + * @returns Validated token response with typed fields 162 + * @throws {TokenValidationError} When token response is invalid 163 + */ 164 + export function validateTokenResponse( 165 + response: unknown, 166 + ): ValidatedTokenResponse { 167 + if (!response || typeof response !== "object") { 168 + throw new TokenValidationError("token response is not an object"); 169 + } 170 + 171 + const r = response as Record<string, unknown>; 172 + 173 + if (typeof r.access_token !== "string" || !r.access_token) { 174 + throw new TokenValidationError("missing or empty 'access_token'"); 175 + } 176 + 177 + if (typeof r.token_type !== "string") { 178 + throw new TokenValidationError("missing 'token_type'"); 179 + } 180 + if (r.token_type.toLowerCase() !== "dpop") { 181 + throw new TokenValidationError( 182 + `unexpected token_type "${r.token_type}", expected "DPoP"`, 183 + ); 184 + } 185 + 186 + if (typeof r.sub !== "string" || !r.sub) { 187 + throw new TokenValidationError("missing 'sub' claim"); 188 + } 189 + if (!r.sub.startsWith("did:")) { 190 + throw new TokenValidationError( 191 + `invalid 'sub' claim "${r.sub}", must start with "did:"`, 192 + ); 193 + } 194 + 195 + if (typeof r.scope !== "string" || !r.scope) { 196 + throw new TokenValidationError("missing 'scope'"); 197 + } 198 + if (!r.scope.includes("atproto")) { 199 + throw new TokenValidationError( 200 + `scope "${r.scope}" does not include required "atproto" scope`, 201 + ); 202 + } 203 + 204 + if (typeof r.expires_in !== "number" || r.expires_in <= 0) { 205 + throw new TokenValidationError( 206 + `invalid 'expires_in' value: ${r.expires_in}`, 207 + ); 208 + } 209 + 210 + if (r.refresh_token !== undefined && typeof r.refresh_token !== "string") { 211 + throw new TokenValidationError("'refresh_token' must be a string if present"); 212 + } 213 + 214 + return { 215 + access_token: r.access_token, 216 + token_type: r.token_type, 217 + scope: r.scope, 218 + sub: r.sub, 219 + expires_in: r.expires_in, 220 + refresh_token: r.refresh_token as string | undefined, 221 + }; 222 + }
+110
tests/dpop_test.ts
··· 1 + import { assertEquals, assertNotEquals } from "@std/assert"; 2 + import { generateDPoPKeyPair, generateDPoPProof } from "../src/dpop.ts"; 3 + import { decodeJwt } from "@panva/jose"; 4 + 5 + Deno.test("DPoP proof - htu normalization", async (t) => { 6 + const keyPair = await generateDPoPKeyPair(); 7 + 8 + await t.step("strips query parameters from htu", async () => { 9 + const proof = await generateDPoPProof( 10 + "GET", 11 + "https://example.com/api?foo=bar&baz=qux", 12 + keyPair.privateKey, 13 + keyPair.publicKeyJWK, 14 + ); 15 + const payload = decodeJwt(proof); 16 + assertEquals(payload.htu, "https://example.com/api"); 17 + }); 18 + 19 + await t.step("strips fragment from htu", async () => { 20 + const proof = await generateDPoPProof( 21 + "POST", 22 + "https://example.com/api#section", 23 + keyPair.privateKey, 24 + keyPair.publicKeyJWK, 25 + ); 26 + const payload = decodeJwt(proof); 27 + assertEquals(payload.htu, "https://example.com/api"); 28 + }); 29 + 30 + await t.step("preserves path in htu", async () => { 31 + const proof = await generateDPoPProof( 32 + "GET", 33 + "https://example.com/oauth/token", 34 + keyPair.privateKey, 35 + keyPair.publicKeyJWK, 36 + ); 37 + const payload = decodeJwt(proof); 38 + assertEquals(payload.htu, "https://example.com/oauth/token"); 39 + }); 40 + 41 + await t.step("includes nonce when provided", async () => { 42 + const proof = await generateDPoPProof( 43 + "POST", 44 + "https://example.com/oauth/token", 45 + keyPair.privateKey, 46 + keyPair.publicKeyJWK, 47 + undefined, 48 + "server-nonce-123", 49 + ); 50 + const payload = decodeJwt(proof); 51 + assertEquals(payload.nonce, "server-nonce-123"); 52 + }); 53 + 54 + await t.step("generates unique jti for each proof", async () => { 55 + const proof1 = await generateDPoPProof( 56 + "GET", 57 + "https://example.com/api", 58 + keyPair.privateKey, 59 + keyPair.publicKeyJWK, 60 + ); 61 + const proof2 = await generateDPoPProof( 62 + "GET", 63 + "https://example.com/api", 64 + keyPair.privateKey, 65 + keyPair.publicKeyJWK, 66 + ); 67 + const payload1 = decodeJwt(proof1); 68 + const payload2 = decodeJwt(proof2); 69 + assertNotEquals(payload1.jti, payload2.jti); 70 + }); 71 + }); 72 + 73 + Deno.test("DPoP nonce cache", async (t) => { 74 + // Import cache functions 75 + const { getCachedNonce, updateNonceCache } = await import("../src/dpop.ts"); 76 + 77 + await t.step("returns undefined for unknown origins", () => { 78 + const nonce = getCachedNonce("https://unknown-origin.example.com/path"); 79 + assertEquals(nonce, undefined); 80 + }); 81 + 82 + await t.step("stores and retrieves nonce per origin", () => { 83 + const mockResponse = new Response(null, { 84 + headers: { "DPoP-Nonce": "nonce-abc" }, 85 + }); 86 + updateNonceCache("https://cache-test.example.com/oauth/token", mockResponse); 87 + 88 + assertEquals(getCachedNonce("https://cache-test.example.com/other"), "nonce-abc"); 89 + }); 90 + 91 + await t.step("updates nonce from new response", () => { 92 + const response1 = new Response(null, { 93 + headers: { "DPoP-Nonce": "nonce-1" }, 94 + }); 95 + updateNonceCache("https://update-test.example.com/a", response1); 96 + assertEquals(getCachedNonce("https://update-test.example.com/b"), "nonce-1"); 97 + 98 + const response2 = new Response(null, { 99 + headers: { "DPoP-Nonce": "nonce-2" }, 100 + }); 101 + updateNonceCache("https://update-test.example.com/c", response2); 102 + assertEquals(getCachedNonce("https://update-test.example.com/d"), "nonce-2"); 103 + }); 104 + 105 + await t.step("ignores responses without DPoP-Nonce header", () => { 106 + const response = new Response(null); 107 + updateNonceCache("https://no-nonce.example.com/path", response); 108 + assertEquals(getCachedNonce("https://no-nonce.example.com/path"), undefined); 109 + }); 110 + });
+43 -1
tests/errors_test.ts
··· 1 - import { assertEquals, assertInstanceOf } from "@std/assert"; 1 + import { assert, assertEquals, assertInstanceOf } from "@std/assert"; 2 2 import { 3 3 AuthorizationError, 4 4 DPoPError, 5 5 HandleResolutionError, 6 6 InvalidHandleError, 7 7 InvalidStateError, 8 + IssuerMismatchError, 9 + MetadataValidationError, 8 10 OAuthError, 9 11 PDSDiscoveryError, 10 12 SessionError, 11 13 TokenExchangeError, 14 + TokenValidationError, 12 15 } from "../src/errors.ts"; 13 16 14 17 Deno.test("OAuthError", async (t) => { ··· 207 210 assertInstanceOf(error, AuthorizationError); 208 211 }); 209 212 }); 213 + 214 + // New error types 215 + 216 + Deno.test("MetadataValidationError", async (t) => { 217 + await t.step("should create error with message", () => { 218 + const error = new MetadataValidationError("missing issuer field"); 219 + assertEquals(error.name, "MetadataValidationError"); 220 + assert(error.message.includes("missing issuer field")); 221 + assertInstanceOf(error, OAuthError); 222 + }); 223 + 224 + await t.step("should chain cause", () => { 225 + const cause = new Error("parse error"); 226 + const error = new MetadataValidationError("invalid metadata", cause); 227 + assertEquals(error.cause, cause); 228 + }); 229 + }); 230 + 231 + Deno.test("IssuerMismatchError", async (t) => { 232 + await t.step("should include expected and actual issuers", () => { 233 + const error = new IssuerMismatchError("https://expected.com", "https://actual.com"); 234 + assertEquals(error.name, "IssuerMismatchError"); 235 + assertEquals(error.expected, "https://expected.com"); 236 + assertEquals(error.actual, "https://actual.com"); 237 + assert(error.message.includes("https://expected.com")); 238 + assert(error.message.includes("https://actual.com")); 239 + assertInstanceOf(error, OAuthError); 240 + }); 241 + }); 242 + 243 + Deno.test("TokenValidationError", async (t) => { 244 + await t.step("should create error with message", () => { 245 + const error = new TokenValidationError("missing sub claim"); 246 + assertEquals(error.name, "TokenValidationError"); 247 + assert(error.message.includes("missing sub claim")); 248 + assertInstanceOf(error, TokenExchangeError); 249 + assertInstanceOf(error, OAuthError); 250 + }); 251 + });
+278
tests/validation_test.ts
··· 1 + import { assertEquals, assertThrows } from "@std/assert"; 2 + import { 3 + requireHttpsUrl, 4 + validateAuthServerMetadata, 5 + validateTokenResponse, 6 + } from "../src/validation.ts"; 7 + import { MetadataValidationError, TokenValidationError } from "../src/errors.ts"; 8 + 9 + // --- requireHttpsUrl --- 10 + 11 + Deno.test("requireHttpsUrl", async (t) => { 12 + await t.step("accepts HTTPS URLs", () => { 13 + requireHttpsUrl("https://example.com", "test"); 14 + requireHttpsUrl("https://auth.example.com/path", "test"); 15 + }); 16 + 17 + await t.step("rejects HTTP URLs", () => { 18 + assertThrows( 19 + () => requireHttpsUrl("http://example.com", "test"), 20 + MetadataValidationError, 21 + "must use HTTPS", 22 + ); 23 + }); 24 + 25 + await t.step("rejects invalid URLs", () => { 26 + assertThrows( 27 + () => requireHttpsUrl("not-a-url", "test"), 28 + MetadataValidationError, 29 + "not a valid URL", 30 + ); 31 + }); 32 + }); 33 + 34 + // --- validateAuthServerMetadata --- 35 + 36 + Deno.test("validateAuthServerMetadata", async (t) => { 37 + const validMetadata = { 38 + issuer: "https://bsky.social", 39 + authorization_endpoint: "https://bsky.social/oauth/authorize", 40 + token_endpoint: "https://bsky.social/oauth/token", 41 + pushed_authorization_request_endpoint: "https://bsky.social/oauth/par", 42 + revocation_endpoint: "https://bsky.social/oauth/revoke", 43 + dpop_signing_alg_values_supported: ["ES256"], 44 + }; 45 + 46 + await t.step("accepts valid metadata", () => { 47 + const result = validateAuthServerMetadata(validMetadata, "https://bsky.social"); 48 + assertEquals(result.issuer, "https://bsky.social"); 49 + assertEquals(result.authorization_endpoint, "https://bsky.social/oauth/authorize"); 50 + assertEquals(result.token_endpoint, "https://bsky.social/oauth/token"); 51 + }); 52 + 53 + await t.step("rejects non-object metadata", () => { 54 + assertThrows( 55 + () => validateAuthServerMetadata(null, "https://bsky.social"), 56 + MetadataValidationError, 57 + "not an object", 58 + ); 59 + assertThrows( 60 + () => validateAuthServerMetadata("string", "https://bsky.social"), 61 + MetadataValidationError, 62 + "not an object", 63 + ); 64 + }); 65 + 66 + await t.step("rejects missing issuer", () => { 67 + assertThrows( 68 + () => 69 + validateAuthServerMetadata( 70 + { ...validMetadata, issuer: undefined }, 71 + "https://bsky.social", 72 + ), 73 + MetadataValidationError, 74 + "issuer", 75 + ); 76 + }); 77 + 78 + await t.step("rejects issuer origin mismatch", () => { 79 + assertThrows( 80 + () => 81 + validateAuthServerMetadata( 82 + { ...validMetadata, issuer: "https://evil.com" }, 83 + "https://bsky.social", 84 + ), 85 + MetadataValidationError, 86 + "does not match", 87 + ); 88 + }); 89 + 90 + await t.step("accepts issuer with same origin but different path", () => { 91 + const md = { 92 + ...validMetadata, 93 + issuer: "https://bsky.social/some/path", 94 + }; 95 + const result = validateAuthServerMetadata(md, "https://bsky.social"); 96 + assertEquals(result.issuer, "https://bsky.social/some/path"); 97 + }); 98 + 99 + await t.step("rejects missing authorization_endpoint", () => { 100 + assertThrows( 101 + () => 102 + validateAuthServerMetadata( 103 + { ...validMetadata, authorization_endpoint: "" }, 104 + "https://bsky.social", 105 + ), 106 + MetadataValidationError, 107 + "authorization_endpoint", 108 + ); 109 + }); 110 + 111 + await t.step("rejects missing token_endpoint", () => { 112 + assertThrows( 113 + () => 114 + validateAuthServerMetadata( 115 + { ...validMetadata, token_endpoint: "" }, 116 + "https://bsky.social", 117 + ), 118 + MetadataValidationError, 119 + "token_endpoint", 120 + ); 121 + }); 122 + 123 + await t.step("rejects HTTP endpoints", () => { 124 + assertThrows( 125 + () => 126 + validateAuthServerMetadata( 127 + { ...validMetadata, authorization_endpoint: "http://bsky.social/oauth/authorize" }, 128 + "https://bsky.social", 129 + ), 130 + MetadataValidationError, 131 + "must use HTTPS", 132 + ); 133 + }); 134 + 135 + await t.step("rejects DPoP algs without ES256", () => { 136 + assertThrows( 137 + () => 138 + validateAuthServerMetadata( 139 + { ...validMetadata, dpop_signing_alg_values_supported: ["RS256"] }, 140 + "https://bsky.social", 141 + ), 142 + MetadataValidationError, 143 + "ES256", 144 + ); 145 + }); 146 + 147 + await t.step("accepts metadata without dpop_signing_alg_values_supported", () => { 148 + const md = { ...validMetadata }; 149 + delete (md as Record<string, unknown>).dpop_signing_alg_values_supported; 150 + const result = validateAuthServerMetadata(md, "https://bsky.social"); 151 + assertEquals(result.dpop_signing_alg_values_supported, undefined); 152 + }); 153 + 154 + await t.step("accepts metadata without optional endpoints", () => { 155 + const md = { 156 + issuer: "https://bsky.social", 157 + authorization_endpoint: "https://bsky.social/oauth/authorize", 158 + token_endpoint: "https://bsky.social/oauth/token", 159 + }; 160 + const result = validateAuthServerMetadata(md, "https://bsky.social"); 161 + assertEquals(result.pushed_authorization_request_endpoint, undefined); 162 + assertEquals(result.revocation_endpoint, undefined); 163 + }); 164 + }); 165 + 166 + // --- validateTokenResponse --- 167 + 168 + Deno.test("validateTokenResponse", async (t) => { 169 + const validResponse = { 170 + access_token: "at_token_123", 171 + token_type: "DPoP", 172 + scope: "atproto transition:generic", 173 + sub: "did:plc:abc123", 174 + expires_in: 3600, 175 + refresh_token: "rt_token_456", 176 + }; 177 + 178 + await t.step("accepts valid token response", () => { 179 + const result = validateTokenResponse(validResponse); 180 + assertEquals(result.access_token, "at_token_123"); 181 + assertEquals(result.token_type, "DPoP"); 182 + assertEquals(result.scope, "atproto transition:generic"); 183 + assertEquals(result.sub, "did:plc:abc123"); 184 + assertEquals(result.expires_in, 3600); 185 + assertEquals(result.refresh_token, "rt_token_456"); 186 + }); 187 + 188 + await t.step("accepts DPoP case-insensitive", () => { 189 + const result = validateTokenResponse({ ...validResponse, token_type: "dpop" }); 190 + assertEquals(result.token_type, "dpop"); 191 + }); 192 + 193 + await t.step("rejects non-object response", () => { 194 + assertThrows( 195 + () => validateTokenResponse(null), 196 + TokenValidationError, 197 + "not an object", 198 + ); 199 + }); 200 + 201 + await t.step("rejects missing access_token", () => { 202 + assertThrows( 203 + () => validateTokenResponse({ ...validResponse, access_token: "" }), 204 + TokenValidationError, 205 + "access_token", 206 + ); 207 + }); 208 + 209 + await t.step("rejects wrong token_type", () => { 210 + assertThrows( 211 + () => validateTokenResponse({ ...validResponse, token_type: "Bearer" }), 212 + TokenValidationError, 213 + "DPoP", 214 + ); 215 + }); 216 + 217 + await t.step("rejects missing sub", () => { 218 + assertThrows( 219 + () => validateTokenResponse({ ...validResponse, sub: "" }), 220 + TokenValidationError, 221 + "sub", 222 + ); 223 + }); 224 + 225 + await t.step("rejects sub not starting with did:", () => { 226 + assertThrows( 227 + () => validateTokenResponse({ ...validResponse, sub: "user:abc" }), 228 + TokenValidationError, 229 + "did:", 230 + ); 231 + }); 232 + 233 + await t.step("rejects missing scope", () => { 234 + assertThrows( 235 + () => validateTokenResponse({ ...validResponse, scope: "" }), 236 + TokenValidationError, 237 + "scope", 238 + ); 239 + }); 240 + 241 + await t.step("rejects scope without atproto", () => { 242 + assertThrows( 243 + () => validateTokenResponse({ ...validResponse, scope: "openid profile" }), 244 + TokenValidationError, 245 + "atproto", 246 + ); 247 + }); 248 + 249 + await t.step("rejects zero expires_in", () => { 250 + assertThrows( 251 + () => validateTokenResponse({ ...validResponse, expires_in: 0 }), 252 + TokenValidationError, 253 + "expires_in", 254 + ); 255 + }); 256 + 257 + await t.step("rejects negative expires_in", () => { 258 + assertThrows( 259 + () => validateTokenResponse({ ...validResponse, expires_in: -1 }), 260 + TokenValidationError, 261 + "expires_in", 262 + ); 263 + }); 264 + 265 + await t.step("accepts response without refresh_token", () => { 266 + const { refresh_token: _, ...noRefresh } = validResponse; 267 + const result = validateTokenResponse(noRefresh); 268 + assertEquals(result.refresh_token, undefined); 269 + }); 270 + 271 + await t.step("rejects non-string refresh_token", () => { 272 + assertThrows( 273 + () => validateTokenResponse({ ...validResponse, refresh_token: 123 }), 274 + TokenValidationError, 275 + "refresh_token", 276 + ); 277 + }); 278 + });