A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
at main 222 lines 7.2 kB view raw
1/** 2 * @fileoverview Validation utilities for OAuth metadata and token responses 3 * @module 4 */ 5 6import { MetadataValidationError, TokenValidationError } from "./errors.ts"; 7 8/** 9 * Validated authorization server metadata with required fields guaranteed present. 10 */ 11export 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 */ 23export 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 */ 39export 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 */ 67export 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 */ 164export 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}