A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
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}