a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

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

refactor(oauth-node-client): use oauth-types and oauth-keyset package

Mary a2e12fc1 751161fb

+77 -2279
+5
.changeset/ninety-mirrors-kick.md
··· 1 + --- 2 + '@atcute/oauth-node-client': patch 3 + --- 4 + 5 + make use of @atcute/oauth-types and @atcute/oauth-keyset
-112
packages/oauth/node-client/lib/build-client-metadata.test.ts
··· 1 - import { describe, expect, it } from 'vitest'; 2 - 3 - import { generatePrivateKey } from './keyset/import-key.js'; 4 - import { Keyset } from './keyset/keyset.js'; 5 - import type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 6 - 7 - import { buildClientMetadata } from './build-client-metadata.js'; 8 - 9 - const createValidMetadata = (): ConfidentialClientMetadata => ({ 10 - client_id: 'https://example.com/client-metadata.json', 11 - client_name: 'Test Client', 12 - client_uri: 'https://example.com', 13 - redirect_uris: ['https://example.com/callback'], 14 - scope: 'atproto', 15 - }); 16 - 17 - describe('buildClientMetadata', () => { 18 - describe('valid metadata', () => { 19 - it('accepts valid metadata with ES256 key', async () => { 20 - const key = await generatePrivateKey('key-1', 'ES256'); 21 - const keyset = new Keyset([key]); 22 - const metadata = createValidMetadata(); 23 - 24 - const result = buildClientMetadata(metadata, keyset); 25 - 26 - expect(result.client_id).toBe('https://example.com/client-metadata.json'); 27 - expect(result.token_endpoint_auth_method).toBe('private_key_jwt'); 28 - expect(result.dpop_bound_access_tokens).toBe(true); 29 - }); 30 - 31 - it('populates jwks from keyset if jwks_uri is not provided', async () => { 32 - const key = await generatePrivateKey('key-1', 'ES256'); 33 - const keyset = new Keyset([key]); 34 - const metadata = createValidMetadata(); 35 - 36 - const result = buildClientMetadata(metadata, keyset); 37 - 38 - expect(result.jwks).toBeDefined(); 39 - expect(result.jwks!.keys).toHaveLength(1); 40 - expect(result.jwks!.keys[0].kid).toBe('key-1'); 41 - }); 42 - 43 - it('uses jwks_uri when provided', async () => { 44 - const key = await generatePrivateKey('key-1', 'ES256'); 45 - const keyset = new Keyset([key]); 46 - const metadata: ConfidentialClientMetadata = { 47 - ...createValidMetadata(), 48 - jwks_uri: 'https://example.com/.well-known/jwks.json', 49 - }; 50 - 51 - const result = buildClientMetadata(metadata, keyset); 52 - expect(result.jwks_uri).toBe('https://example.com/.well-known/jwks.json'); 53 - expect(result.jwks).toBeUndefined(); 54 - }); 55 - 56 - it('supports multiple keys (including ES256)', async () => { 57 - const key1 = await generatePrivateKey('key-1', 'ES256'); 58 - const key2 = await generatePrivateKey('key-2', 'ES384'); 59 - const keyset = new Keyset([key1, key2]); 60 - const metadata = createValidMetadata(); 61 - 62 - const result = buildClientMetadata(metadata, keyset); 63 - 64 - expect(result.jwks!.keys).toHaveLength(2); 65 - }); 66 - }); 67 - 68 - describe('invalid metadata', () => { 69 - it('rejects keyset without ES256 key', async () => { 70 - const key = await generatePrivateKey('key-1', 'ES384'); 71 - const keyset = new Keyset([key]); 72 - const metadata = createValidMetadata(); 73 - 74 - expect(() => buildClientMetadata(metadata, keyset)).toThrow( 75 - '"private_key_jwt" requires at least one "ES256" signing key', 76 - ); 77 - }); 78 - 79 - it('rejects loopback client_id', async () => { 80 - const key = await generatePrivateKey('key-1', 'ES256'); 81 - const keyset = new Keyset([key]); 82 - const metadata: ConfidentialClientMetadata = { 83 - ...createValidMetadata(), 84 - client_id: 'http://127.0.0.1:8080/callback', 85 - }; 86 - 87 - expect(() => buildClientMetadata(metadata, keyset)).toThrow(); 88 - }); 89 - 90 - it('rejects missing atproto scope', async () => { 91 - const key = await generatePrivateKey('key-1', 'ES256'); 92 - const keyset = new Keyset([key]); 93 - const metadata: ConfidentialClientMetadata = { 94 - ...createValidMetadata(), 95 - scope: 'openid profile', 96 - }; 97 - 98 - expect(() => buildClientMetadata(metadata, keyset)).toThrow(); 99 - }); 100 - 101 - it('rejects jwks_uri with different origin', async () => { 102 - const key = await generatePrivateKey('key-1', 'ES256'); 103 - const keyset = new Keyset([key]); 104 - const metadata: ConfidentialClientMetadata = { 105 - ...createValidMetadata(), 106 - jwks_uri: 'https://other.example.com/.well-known/jwks.json', 107 - }; 108 - 109 - expect(() => buildClientMetadata(metadata, keyset)).not.toThrow(); 110 - }); 111 - }); 112 - });
-71
packages/oauth/node-client/lib/build-client-metadata.ts
··· 1 - import { FALLBACK_ALG } from './constants.js'; 2 - import type { Keyset } from './keyset/keyset.js'; 3 - import { 4 - confidentialClientMetadataSchema, 5 - type ConfidentialClientMetadata, 6 - } from './schemas/atcute-confidential-client-metadata.js'; 7 - import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 8 - 9 - /** 10 - * builds an atproto client metadata 11 - * 12 - * 13 - * @param input client metadata 14 - * @param keyset available keys 15 - * @returns built client metadata 16 - */ 17 - export const buildClientMetadata = ( 18 - input: ConfidentialClientMetadata, 19 - keyset: Keyset, 20 - ): OAuthClientMetadata => { 21 - // validate user-facing schema is correct 22 - const conf = confidentialClientMetadataSchema.parse(input, { mode: 'passthrough' }); 23 - 24 - // build full OAuth client metadata (atproto defaults and requirements) 25 - const metadata: OAuthClientMetadata = { 26 - client_id: conf.client_id, 27 - client_name: conf.client_name, 28 - client_uri: conf.client_uri, 29 - policy_uri: conf.policy_uri, 30 - tos_uri: conf.tos_uri, 31 - logo_uri: conf.logo_uri, 32 - redirect_uris: conf.redirect_uris, 33 - scope: Array.isArray(conf.scope) ? conf.scope.join(' ') : conf.scope, 34 - 35 - application_type: 'web', 36 - subject_type: 'public', 37 - response_types: ['code'], 38 - grant_types: ['authorization_code', 'refresh_token'], 39 - 40 - token_endpoint_auth_method: 'private_key_jwt', 41 - token_endpoint_auth_signing_alg: FALLBACK_ALG, 42 - dpop_bound_access_tokens: true, 43 - 44 - jwks_uri: conf.jwks_uri, 45 - jwks: conf.jwks_uri ? undefined : (keyset.publicJwks as OAuthClientMetadata['jwks']), 46 - }; 47 - 48 - // ensure at least one key supports the fallback algorithm 49 - const signingKeys = Array.from(keyset); 50 - if (!signingKeys.some((key) => key.alg === FALLBACK_ALG)) { 51 - throw new TypeError(`"private_key_jwt" requires at least one "${FALLBACK_ALG}" signing key`); 52 - } 53 - 54 - // if jwks provided inline, ensure ALL signing keys are present 55 - if (metadata.jwks) { 56 - const jwksKids = new Set( 57 - metadata.jwks.keys 58 - .filter((k) => !k.revoked) 59 - .map((k) => k.kid) 60 - .filter(Boolean), 61 - ); 62 - 63 - for (const key of signingKeys) { 64 - if (!jwksKids.has(key.kid)) { 65 - throw new TypeError(`signing key "${key.kid}" not found in jwks`); 66 - } 67 - } 68 - } 69 - 70 - return metadata; 71 - };
-6
packages/oauth/node-client/lib/constants.ts
··· 12 12 13 13 /** max size for PAR responses */ 14 14 export const PAR_RESPONSE_MAX_SIZE = 1024; // 1KB 15 - 16 - /** default algorithm per atproto spec */ 17 - export const FALLBACK_ALG = 'ES256'; 18 - 19 - /** JWT bearer assertion type for `private_key_jwt` authentication */ 20 - export const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
+26 -17
packages/oauth/node-client/lib/index.ts
··· 1 - export { buildClientMetadata } from './build-client-metadata.js'; 1 + export { 2 + exportJwkKey, 3 + exportPkcs8Key, 4 + generatePrivateKey, 5 + importJwkKey, 6 + importPkcs8Key, 7 + Keyset, 8 + type ImportKeyOptions, 9 + type KeySearchOptions, 10 + type PrivateKey, 11 + type SigningAlgorithm, 12 + } from '@atcute/oauth-keyset'; 13 + 14 + export { 15 + buildClientMetadata, 16 + CLIENT_ASSERTION_TYPE_JWT_BEARER, 17 + FALLBACK_ALG, 18 + type AtprotoAuthorizationServerMetadata, 19 + type AtprotoProtectedResourceMetadata, 20 + type ConfidentialClientMetadata, 21 + type OAuthAuthorizationServerMetadata, 22 + type OAuthClientMetadata, 23 + type OAuthProtectedResourceMetadata, 24 + type OAuthResponseMode, 25 + } from '@atcute/oauth-types'; 26 + 2 27 export * as scope from './scope.js'; 3 28 export { 4 29 OAuthClient, ··· 16 41 export type { SessionEvent, SessionEventListener } from './session-getter.js'; 17 42 18 43 export { 19 - exportJwkKey, 20 - exportPkcs8Key, 21 - generatePrivateKey, 22 - importJwkKey, 23 - importPkcs8Key, 24 - } from './keyset/import-key.js'; 25 - export { Keyset } from './keyset/keyset.js'; 26 - export type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './keyset/types.js'; 27 - 28 - export { 29 44 AuthMethodUnsatisfiableError, 30 45 OAuthCallbackError, 31 46 OAuthResolverError, ··· 45 60 export type { SessionStore, StoredSession } from './types/sessions.js'; 46 61 export type { StateStore, StoredState } from './types/states.js'; 47 62 export type { TokenSet } from './types/token-set.js'; 48 - 49 - export type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 50 - export type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 51 - export type { AtprotoProtectedResourceMetadata } from './schemas/atproto-protected-resource-metadata.js'; 52 - export type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 53 - export type { OAuthResponseMode } from './schemas/oauth-response-mode.js';
-215
packages/oauth/node-client/lib/keyset/import-key.test.ts
··· 1 - import { describe, expect, it } from 'vitest'; 2 - 3 - import { 4 - exportJwkKey, 5 - exportPkcs8Key, 6 - generatePrivateKey, 7 - importJwkKey, 8 - importPkcs8Key, 9 - } from './import-key.js'; 10 - 11 - describe('generatePrivateKey', () => { 12 - it('should generate ES256 key by default', async () => { 13 - const key = await generatePrivateKey('test-key'); 14 - 15 - expect(key.kid).toBe('test-key'); 16 - expect(key.alg).toBe('ES256'); 17 - expect(key.key).toBeInstanceOf(CryptoKey); 18 - expect(key.publicJwk.kty).toBe('EC'); 19 - expect(key.publicJwk.crv).toBe('P-256'); 20 - }); 21 - 22 - it('should generate key with specified algorithm', async () => { 23 - const key = await generatePrivateKey('test-key', 'ES384'); 24 - 25 - expect(key.alg).toBe('ES384'); 26 - expect(key.publicJwk.crv).toBe('P-384'); 27 - }); 28 - 29 - it('should not include private material in publicJwk', async () => { 30 - const key = await generatePrivateKey('test-key'); 31 - 32 - expect((key.publicJwk as Record<string, unknown>).d).toBeUndefined(); 33 - expect(key.publicJwk.x).toBeDefined(); 34 - expect(key.publicJwk.y).toBeDefined(); 35 - }); 36 - }); 37 - 38 - describe('importJwkKey', () => { 39 - it('should import JWK object', async () => { 40 - const original = await generatePrivateKey('test-key', 'ES256'); 41 - const jwk = await exportJwkKey(original); 42 - 43 - const imported = await importJwkKey(jwk); 44 - 45 - expect(imported.kid).toBe('test-key'); 46 - expect(imported.alg).toBe('ES256'); 47 - }); 48 - 49 - it('should import JSON string', async () => { 50 - const original = await generatePrivateKey('test-key'); 51 - const jwk = await exportJwkKey(original); 52 - const jsonStr = JSON.stringify(jwk); 53 - 54 - const imported = await importJwkKey(jsonStr); 55 - 56 - expect(imported.kid).toBe('test-key'); 57 - }); 58 - 59 - it('should allow overriding kid via options', async () => { 60 - const original = await generatePrivateKey('original-kid'); 61 - const jwk = await exportJwkKey(original); 62 - 63 - const imported = await importJwkKey(jwk, { kid: 'new-kid' }); 64 - 65 - expect(imported.kid).toBe('new-kid'); 66 - }); 67 - 68 - it('should allow overriding alg via options', async () => { 69 - const original = await generatePrivateKey('test-key', 'ES256'); 70 - const jwk = await exportJwkKey(original); 71 - 72 - // ES256 key can also work with ES256 alg override (same key) 73 - const imported = await importJwkKey(jwk, { alg: 'ES256' }); 74 - 75 - expect(imported.alg).toBe('ES256'); 76 - }); 77 - 78 - it('should infer algorithm from EC curve', async () => { 79 - const original = await generatePrivateKey('test-key', 'ES384'); 80 - const jwk = await exportJwkKey(original); 81 - delete jwk.alg; // remove alg to test inference 82 - 83 - const imported = await importJwkKey(jwk); 84 - 85 - expect(imported.alg).toBe('ES384'); 86 - }); 87 - 88 - it('should throw on missing kid', async () => { 89 - const original = await generatePrivateKey('test-key'); 90 - const jwk = await exportJwkKey(original); 91 - delete jwk.kid; 92 - 93 - await expect(importJwkKey(jwk)).rejects.toThrow('kid is required'); 94 - }); 95 - 96 - it('should throw on missing alg for RSA key', async () => { 97 - const original = await generatePrivateKey('test-key', 'RS256'); 98 - const jwk = await exportJwkKey(original); 99 - delete jwk.alg; 100 - 101 - await expect(importJwkKey(jwk)).rejects.toThrow('alg is required'); 102 - }); 103 - 104 - it('should throw on invalid JSON string', async () => { 105 - await expect(importJwkKey('not valid json')).rejects.toThrow('invalid JSON string'); 106 - }); 107 - 108 - it('should throw on non-private key', async () => { 109 - const original = await generatePrivateKey('test-key'); 110 - const publicJwk = { ...original.publicJwk }; 111 - 112 - await expect(importJwkKey(publicJwk)).rejects.toThrow("expected a private key (missing 'd' parameter)"); 113 - }); 114 - 115 - it('should throw on unsupported algorithm', async () => { 116 - const original = await generatePrivateKey('test-key'); 117 - const jwk = await exportJwkKey(original); 118 - 119 - await expect(importJwkKey(jwk, { alg: 'HS256' as any })).rejects.toThrow('unsupported algorithm'); 120 - }); 121 - }); 122 - 123 - describe('importPkcs8Key', () => { 124 - it('should import PKCS#8 PEM', async () => { 125 - const original = await generatePrivateKey('test-key', 'ES256'); 126 - const pem = await exportPkcs8Key(original); 127 - 128 - const imported = await importPkcs8Key(pem, { kid: 'imported-key', alg: 'ES256' }); 129 - 130 - expect(imported.kid).toBe('imported-key'); 131 - expect(imported.alg).toBe('ES256'); 132 - expect(imported.key).toBeInstanceOf(CryptoKey); 133 - }); 134 - 135 - it('should throw on unsupported algorithm', async () => { 136 - const original = await generatePrivateKey('test-key', 'ES256'); 137 - const pem = await exportPkcs8Key(original); 138 - 139 - await expect(importPkcs8Key(pem, { kid: 'key', alg: 'HS256' as any })).rejects.toThrow( 140 - 'unsupported algorithm', 141 - ); 142 - }); 143 - }); 144 - 145 - describe('round-trip exports', () => { 146 - it('should round-trip through JWK', async () => { 147 - const original = await generatePrivateKey('test-key', 'ES256'); 148 - const jwk = await exportJwkKey(original); 149 - const imported = await importJwkKey(jwk); 150 - 151 - expect(imported.kid).toBe(original.kid); 152 - expect(imported.alg).toBe(original.alg); 153 - expect(imported.publicJwk).toEqual(original.publicJwk); 154 - }); 155 - 156 - it('should round-trip through PKCS#8', async () => { 157 - const original = await generatePrivateKey('test-key', 'ES256'); 158 - const pem = await exportPkcs8Key(original); 159 - const imported = await importPkcs8Key(pem, { kid: 'test-key', alg: 'ES256' }); 160 - 161 - expect(imported.kid).toBe(original.kid); 162 - expect(imported.alg).toBe(original.alg); 163 - // public key should match 164 - expect(imported.publicJwk.x).toBe(original.publicJwk.x); 165 - expect(imported.publicJwk.y).toBe(original.publicJwk.y); 166 - }); 167 - 168 - it('should work with different EC algorithms', async () => { 169 - for (const alg of ['ES256', 'ES384', 'ES512'] as const) { 170 - const original = await generatePrivateKey(`key-${alg}`, alg); 171 - const jwk = await exportJwkKey(original); 172 - const imported = await importJwkKey(jwk); 173 - 174 - expect(imported.alg).toBe(alg); 175 - } 176 - }); 177 - 178 - it('should work with RSA algorithms', async () => { 179 - for (const alg of ['RS256', 'PS256'] as const) { 180 - const original = await generatePrivateKey(`key-${alg}`, alg); 181 - const jwk = await exportJwkKey(original); 182 - const imported = await importJwkKey(jwk); 183 - 184 - expect(imported.alg).toBe(alg); 185 - expect(imported.publicJwk.kty).toBe('RSA'); 186 - } 187 - }); 188 - }); 189 - 190 - describe('exportJwkKey', () => { 191 - it('should include kid and alg', async () => { 192 - const key = await generatePrivateKey('my-key', 'ES384'); 193 - const jwk = await exportJwkKey(key); 194 - 195 - expect(jwk.kid).toBe('my-key'); 196 - expect(jwk.alg).toBe('ES384'); 197 - }); 198 - 199 - it('should include private key material', async () => { 200 - const key = await generatePrivateKey('my-key'); 201 - const jwk = await exportJwkKey(key); 202 - 203 - expect(jwk.d).toBeDefined(); // private key component 204 - }); 205 - }); 206 - 207 - describe('exportPkcs8Key', () => { 208 - it('should return valid PEM format', async () => { 209 - const key = await generatePrivateKey('my-key'); 210 - const pem = await exportPkcs8Key(key); 211 - 212 - expect(pem).toContain('-----BEGIN PRIVATE KEY-----'); 213 - expect(pem).toContain('-----END PRIVATE KEY-----'); 214 - }); 215 - });
-198
packages/oauth/node-client/lib/keyset/import-key.ts
··· 1 - import { type JWK, exportJWK, exportPKCS8, generateKeyPair, importJWK, importPKCS8 } from 'jose'; 2 - 3 - import type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './types.js'; 4 - 5 - const SIGNING_ALGORITHMS: readonly SigningAlgorithm[] = [ 6 - 'ES256', 7 - 'ES384', 8 - 'ES512', 9 - 'PS256', 10 - 'PS384', 11 - 'PS512', 12 - 'RS256', 13 - 'RS384', 14 - 'RS512', 15 - ]; 16 - 17 - /** map EC curve to default algorithm */ 18 - const CURVE_TO_ALG: Record<string, SigningAlgorithm> = { 19 - 'P-256': 'ES256', 20 - 'P-384': 'ES384', 21 - 'P-521': 'ES512', 22 - }; 23 - 24 - const isSigningAlgorithm = (alg: string): alg is SigningAlgorithm => { 25 - return (SIGNING_ALGORITHMS as readonly string[]).includes(alg); 26 - }; 27 - 28 - /** 29 - * generates a new private key for use with `private_key_jwt`. 30 - * 31 - * @param kid key ID to assign to the generated key 32 - * @param alg signing algorithm (defaults to 'ES256') 33 - * @returns private key ready for use in keyset 34 - */ 35 - export const generatePrivateKey = async ( 36 - kid: string, 37 - alg: SigningAlgorithm = 'ES256', 38 - ): Promise<PrivateKey> => { 39 - const { privateKey } = await generateKeyPair(alg, { extractable: true }); 40 - const jwk = await exportJWK(privateKey); 41 - jwk.alg = alg; 42 - jwk.kid = kid; 43 - 44 - const publicJwk = derivePublicJwk(jwk, kid, alg); 45 - 46 - return { kid, alg, key: privateKey, publicJwk }; 47 - }; 48 - 49 - /** 50 - * imports a private key from a JWK object or JSON string. 51 - * 52 - * @param input JWK object or JSON string containing a JWK 53 - * @param options override or provide `kid` and `alg` 54 - * @returns private key ready for use in keyset 55 - * @throws if `kid` cannot be determined, `alg` cannot be determined/inferred, 56 - * or the key format is invalid 57 - * 58 - * resolution order: 59 - * - `kid`: `options.kid` ?? `input.kid` ?? error 60 - * - `alg`: `options.alg` ?? `input.alg` ?? inferred from curve ?? error 61 - * 62 - * algorithm inference (EC keys only): 63 - * - P-256 → ES256, P-384 → ES384, P-521 → ES512 64 - * - RSA keys require explicit `alg` (no inference possible) 65 - */ 66 - export const importJwkKey = async (input: JWK | string, options?: ImportKeyOptions): Promise<PrivateKey> => { 67 - let jwk: JWK; 68 - 69 - if (typeof input === 'string') { 70 - try { 71 - jwk = JSON.parse(input) as JWK; 72 - } catch { 73 - throw new Error(`invalid JSON string`); 74 - } 75 - } else if (typeof input === 'object' && input !== null && 'kty' in input) { 76 - jwk = input; 77 - } else { 78 - throw new Error(`invalid input: expected JWK object or JSON string`); 79 - } 80 - 81 - // resolve kid 82 - const kid = options?.kid ?? jwk.kid; 83 - if (!kid) { 84 - throw new Error(`kid is required: provide via options or include in JWK`); 85 - } 86 - 87 - // resolve alg 88 - let alg = options?.alg ?? jwk.alg; 89 - if (!alg) { 90 - // try to infer from EC curve 91 - const crv = (jwk as { crv?: string }).crv; 92 - if (crv && crv in CURVE_TO_ALG) { 93 - alg = CURVE_TO_ALG[crv]; 94 - } else { 95 - throw new Error( 96 - `alg is required: provide via options, include in JWK, or use an EC key with a known curve`, 97 - ); 98 - } 99 - } 100 - 101 - if (!isSigningAlgorithm(alg)) { 102 - throw new Error(`unsupported algorithm: ${alg}`); 103 - } 104 - 105 - // verify this is a private key (has 'd' parameter for asymmetric keys) 106 - if (!('d' in jwk) || !jwk.d) { 107 - throw new Error(`expected a private key (missing 'd' parameter)`); 108 - } 109 - 110 - // import the JWK 111 - const imported = await importJWK(jwk, alg); 112 - if (!(imported instanceof CryptoKey)) { 113 - throw new Error(`expected asymmetric key, got symmetric`); 114 - } 115 - 116 - // derive public JWK by removing private components 117 - const publicJwk = derivePublicJwk(jwk, kid, alg); 118 - 119 - return { kid, alg, key: imported, publicJwk }; 120 - }; 121 - 122 - /** 123 - * imports a private key from a PKCS#8 PEM string. 124 - * 125 - * @param pem PKCS#8 PEM string (starts with '-----BEGIN PRIVATE KEY-----') 126 - * @param options must include `kid` and `alg` 127 - * @returns private key ready for use in keyset 128 - */ 129 - export const importPkcs8Key = async ( 130 - pem: string, 131 - options: Required<ImportKeyOptions>, 132 - ): Promise<PrivateKey> => { 133 - const { kid, alg } = options; 134 - 135 - if (!isSigningAlgorithm(alg)) { 136 - throw new Error(`unsupported algorithm: ${alg}`); 137 - } 138 - 139 - const imported = await importPKCS8(pem, alg, { extractable: true }); 140 - if (!(imported instanceof CryptoKey)) { 141 - throw new Error(`expected asymmetric key, got symmetric`); 142 - } 143 - 144 - const jwk = await exportJWK(imported); 145 - jwk.alg = alg; 146 - jwk.kid = kid; 147 - 148 - const publicJwk = derivePublicJwk(jwk, kid, alg); 149 - 150 - return { kid, alg, key: imported, publicJwk }; 151 - }; 152 - 153 - /** 154 - * exports a private key to JWK format. 155 - * 156 - * @param key private key to export 157 - * @returns JWK with `kid` and `alg` set 158 - */ 159 - export const exportJwkKey = async (key: PrivateKey): Promise<JWK> => { 160 - const jwk = await exportJWK(key.key); 161 - jwk.kid = key.kid; 162 - jwk.alg = key.alg; 163 - return jwk; 164 - }; 165 - 166 - /** 167 - * exports a private key to PKCS#8 PEM format. 168 - * 169 - * @param key private key to export 170 - * @returns PKCS#8 PEM string 171 - */ 172 - export const exportPkcs8Key = async (key: PrivateKey): Promise<string> => { 173 - return exportPKCS8(key.key); 174 - }; 175 - 176 - /** 177 - * derives a public JWK from a private JWK by removing private key material. 178 - */ 179 - const derivePublicJwk = (privateJwk: JWK, kid: string, alg: string): JWK => { 180 - const { kty } = privateJwk; 181 - 182 - if (kty === 'EC') { 183 - const { crv, x, y } = privateJwk as JWK & { crv: string; x: string; y: string }; 184 - return { kty, crv, x, y, kid, alg, use: 'sig' }; 185 - } 186 - 187 - if (kty === 'RSA') { 188 - const { n, e } = privateJwk as JWK & { n: string; e: string }; 189 - return { kty, n, e, kid, alg, use: 'sig' }; 190 - } 191 - 192 - if (kty === 'OKP') { 193 - const { crv, x } = privateJwk as JWK & { crv: string; x: string }; 194 - return { kty, crv, x, kid, alg, use: 'sig' }; 195 - } 196 - 197 - throw new Error(`unsupported key type: ${kty}`); 198 - };
-185
packages/oauth/node-client/lib/keyset/keyset.test.ts
··· 1 - import { describe, expect, it } from 'vitest'; 2 - 3 - import { generatePrivateKey } from './import-key.js'; 4 - import { Keyset } from './keyset.js'; 5 - 6 - describe('Keyset', () => { 7 - describe('constructor', () => { 8 - it('should create keyset with valid keys', async () => { 9 - const key = await generatePrivateKey('key-1'); 10 - const keyset = new Keyset([key]); 11 - 12 - expect(keyset.size).toBe(1); 13 - }); 14 - 15 - it('should reject empty keyset', () => { 16 - expect(() => new Keyset([])).toThrow('keyset must contain at least one key'); 17 - }); 18 - 19 - it('should reject duplicate key IDs', async () => { 20 - const key1 = await generatePrivateKey('same-id'); 21 - const key2 = await generatePrivateKey('same-id'); 22 - 23 - expect(() => new Keyset([key1, key2])).toThrow('duplicate key ID: same-id'); 24 - }); 25 - 26 - it('should accept multiple keys with different IDs', async () => { 27 - const key1 = await generatePrivateKey('key-1'); 28 - const key2 = await generatePrivateKey('key-2', 'ES384'); 29 - 30 - const keyset = new Keyset([key1, key2]); 31 - expect(keyset.size).toBe(2); 32 - }); 33 - }); 34 - 35 - describe('find', () => { 36 - it('should find key by kid', async () => { 37 - const key1 = await generatePrivateKey('key-1'); 38 - const key2 = await generatePrivateKey('key-2'); 39 - const keyset = new Keyset([key1, key2]); 40 - 41 - const found = keyset.find({ kid: 'key-2' }); 42 - expect(found?.kid).toBe('key-2'); 43 - }); 44 - 45 - it('should find key by alg', async () => { 46 - const key1 = await generatePrivateKey('key-1', 'ES256'); 47 - const key2 = await generatePrivateKey('key-2', 'ES384'); 48 - const keyset = new Keyset([key1, key2]); 49 - 50 - const found = keyset.find({ alg: 'ES384' }); 51 - expect(found?.kid).toBe('key-2'); 52 - }); 53 - 54 - it('should find key by alg array', async () => { 55 - const key1 = await generatePrivateKey('key-1', 'ES256'); 56 - const key2 = await generatePrivateKey('key-2', 'ES384'); 57 - const keyset = new Keyset([key1, key2]); 58 - 59 - const found = keyset.find({ alg: ['ES384', 'ES512'] }); 60 - expect(found?.kid).toBe('key-2'); 61 - }); 62 - 63 - it('should return undefined when not found', async () => { 64 - const key = await generatePrivateKey('key-1', 'ES256'); 65 - const keyset = new Keyset([key]); 66 - 67 - expect(keyset.find({ kid: 'nonexistent' })).toBeUndefined(); 68 - expect(keyset.find({ alg: 'RS256' })).toBeUndefined(); 69 - }); 70 - 71 - it('should return first key when no options provided', async () => { 72 - const key1 = await generatePrivateKey('key-1', 'ES256'); 73 - const key2 = await generatePrivateKey('key-2', 'ES384'); 74 - const keyset = new Keyset([key1, key2]); 75 - 76 - // should return based on algorithm preference order 77 - const found = keyset.find(); 78 - expect(found).toBeDefined(); 79 - }); 80 - }); 81 - 82 - describe('get', () => { 83 - it('should get key by kid', async () => { 84 - const key = await generatePrivateKey('key-1'); 85 - const keyset = new Keyset([key]); 86 - 87 - const found = keyset.get({ kid: 'key-1' }); 88 - expect(found.kid).toBe('key-1'); 89 - }); 90 - 91 - it('should throw when key not found', async () => { 92 - const key = await generatePrivateKey('key-1'); 93 - const keyset = new Keyset([key]); 94 - 95 - expect(() => keyset.get({ kid: 'nonexistent' })).toThrow('no key found matching: nonexistent'); 96 - expect(() => keyset.get({ alg: 'RS512' })).toThrow('no key found matching: RS512'); 97 - }); 98 - }); 99 - 100 - describe('list', () => { 101 - it('should list all keys when no options', async () => { 102 - const key1 = await generatePrivateKey('key-1'); 103 - const key2 = await generatePrivateKey('key-2'); 104 - const keyset = new Keyset([key1, key2]); 105 - 106 - const keys = [...keyset.list()]; 107 - expect(keys).toHaveLength(2); 108 - }); 109 - 110 - it('should filter by alg', async () => { 111 - const key1 = await generatePrivateKey('key-1', 'ES256'); 112 - const key2 = await generatePrivateKey('key-2', 'ES384'); 113 - const key3 = await generatePrivateKey('key-3', 'ES256'); 114 - const keyset = new Keyset([key1, key2, key3]); 115 - 116 - const keys = [...keyset.list({ alg: 'ES256' })]; 117 - expect(keys).toHaveLength(2); 118 - expect(keys.map((k) => k.kid)).toEqual(['key-1', 'key-3']); 119 - }); 120 - 121 - it('should sort by algorithm preference', async () => { 122 - const rsKey = await generatePrivateKey('rs-key', 'RS256'); 123 - const esKey = await generatePrivateKey('es-key', 'ES256'); 124 - const keyset = new Keyset([rsKey, esKey]); 125 - 126 - // ES256 should come before RS256 in preference order 127 - const keys = [...keyset.list()]; 128 - expect(keys[0].alg).toBe('ES256'); 129 - expect(keys[1].alg).toBe('RS256'); 130 - }); 131 - }); 132 - 133 - describe('findForSigning', () => { 134 - it('should find compatible key for server algs', async () => { 135 - const key = await generatePrivateKey('key-1', 'ES256'); 136 - const keyset = new Keyset([key]); 137 - 138 - const result = keyset.findForSigning(['ES256', 'ES384']); 139 - expect(result.key.kid).toBe('key-1'); 140 - expect(result.alg).toBe('ES256'); 141 - }); 142 - 143 - it('should default to ES256 when no server algs provided', async () => { 144 - const key = await generatePrivateKey('key-1', 'ES256'); 145 - const keyset = new Keyset([key]); 146 - 147 - const result = keyset.findForSigning(); 148 - expect(result.alg).toBe('ES256'); 149 - }); 150 - 151 - it('should throw when no compatible key', async () => { 152 - const key = await generatePrivateKey('key-1', 'ES384'); 153 - const keyset = new Keyset([key]); 154 - 155 - expect(() => keyset.findForSigning(['RS256', 'RS512'])).toThrow( 156 - 'no key found compatible with server algorithms', 157 - ); 158 - }); 159 - }); 160 - 161 - describe('publicJwks', () => { 162 - it('should return public keys only', async () => { 163 - const key = await generatePrivateKey('key-1', 'ES256'); 164 - const keyset = new Keyset([key]); 165 - 166 - const jwks = keyset.publicJwks; 167 - expect(jwks.keys).toHaveLength(1); 168 - expect(jwks.keys[0].kid).toBe('key-1'); 169 - expect(jwks.keys[0].kty).toBe('EC'); 170 - // should not have private key material 171 - expect((jwks.keys[0] as Record<string, unknown>).d).toBeUndefined(); 172 - }); 173 - }); 174 - 175 - describe('iteration', () => { 176 - it('should be iterable', async () => { 177 - const key1 = await generatePrivateKey('key-1'); 178 - const key2 = await generatePrivateKey('key-2'); 179 - const keyset = new Keyset([key1, key2]); 180 - 181 - const keys = [...keyset]; 182 - expect(keys).toHaveLength(2); 183 - }); 184 - }); 185 - });
-141
packages/oauth/node-client/lib/keyset/keyset.ts
··· 1 - import type { JWK } from 'jose'; 2 - 3 - import type { KeySearchOptions, PrivateKey } from './types.js'; 4 - 5 - /** 6 - * preferred algorithm order for signing. 7 - * EC algorithms first (smaller, faster), then PSS, then PKCS#1 v1.5. 8 - */ 9 - const PREFERRED_ALGORITHMS = [ 10 - 'ES256', 11 - 'ES384', 12 - 'ES512', 13 - 'PS256', 14 - 'PS384', 15 - 'PS512', 16 - 'RS256', 17 - 'RS384', 18 - 'RS512', 19 - ] as const; 20 - 21 - /** 22 - * a collection of private keys for client authentication. 23 - */ 24 - export class Keyset { 25 - private readonly keys: readonly PrivateKey[]; 26 - 27 - /** 28 - * creates a new keyset from an array of private keys. 29 - * 30 - * @param keys array of private keys (at least one required) 31 - * @throws if keyset is empty or contains duplicate key IDs 32 - */ 33 - constructor(keys: PrivateKey[]) { 34 - if (keys.length === 0) { 35 - throw new Error(`keyset must contain at least one key`); 36 - } 37 - 38 - // check for duplicate kids 39 - const kids = new Set<string>(); 40 - for (const key of keys) { 41 - if (kids.has(key.kid)) { 42 - throw new Error(`duplicate key ID: ${key.kid}`); 43 - } 44 - kids.add(key.kid); 45 - } 46 - 47 - this.keys = Object.freeze([...keys]); 48 - } 49 - 50 - /** number of keys in the keyset */ 51 - get size(): number { 52 - return this.keys.length; 53 - } 54 - 55 - /** 56 - * public JWKS for serving at client metadata or jwks_uri. 57 - * pre-computed at import time, safe to inline. 58 - */ 59 - get publicJwks(): { keys: readonly JWK[] } { 60 - return { keys: this.keys.map((k) => k.publicJwk) }; 61 - } 62 - 63 - /** 64 - * finds the first key matching the given criteria. 65 - * 66 - * @param options search criteria (kid and/or alg) 67 - * @returns matching key or undefined 68 - */ 69 - find(options?: KeySearchOptions): PrivateKey | undefined { 70 - for (const key of this.list(options)) { 71 - return key; 72 - } 73 - return undefined; 74 - } 75 - 76 - /** 77 - * gets a key matching the given criteria. 78 - * 79 - * @param options search criteria (kid and/or alg) 80 - * @returns matching key 81 - * @throws if no matching key is found 82 - */ 83 - get(options?: KeySearchOptions): PrivateKey { 84 - const key = this.find(options); 85 - if (!key) { 86 - const desc = options?.kid ?? options?.alg ?? 'any'; 87 - throw new Error(`no key found matching: ${desc}`); 88 - } 89 - return key; 90 - } 91 - 92 - /** 93 - * iterates over keys matching the given criteria, in preference order. 94 - * 95 - * @param options search criteria (kid and/or alg) 96 - */ 97 - *list(options?: KeySearchOptions): Generator<PrivateKey> { 98 - const { kid, alg } = options ?? {}; 99 - const algSet = alg == null ? null : new Set(Array.isArray(alg) ? alg : [alg]); 100 - 101 - // sort keys by algorithm preference 102 - const sorted = [...this.keys].sort((a, b) => { 103 - const aIdx = PREFERRED_ALGORITHMS.indexOf(a.alg as (typeof PREFERRED_ALGORITHMS)[number]); 104 - const bIdx = PREFERRED_ALGORITHMS.indexOf(b.alg as (typeof PREFERRED_ALGORITHMS)[number]); 105 - return aIdx - bIdx; 106 - }); 107 - 108 - for (const key of sorted) { 109 - if (kid != null && key.kid !== kid) { 110 - continue; 111 - } 112 - if (algSet != null && !algSet.has(key.alg)) { 113 - continue; 114 - } 115 - yield key; 116 - } 117 - } 118 - 119 - /** 120 - * finds a key for signing, negotiating algorithm with server's supported list. 121 - * 122 - * @param serverAlgs algorithms supported by the server (from metadata) 123 - * @returns key and negotiated algorithm 124 - * @throws if no compatible key is found 125 - */ 126 - findForSigning(serverAlgs?: readonly string[]): { key: PrivateKey; alg: string } { 127 - // if server doesn't specify, default to ES256 per atproto spec 128 - const algs = serverAlgs ?? ['ES256']; 129 - 130 - const key = this.find({ alg: algs }); 131 - if (!key) { 132 - throw new Error(`no key found compatible with server algorithms: ${algs.join(', ')}`); 133 - } 134 - 135 - return { key, alg: key.alg }; 136 - } 137 - 138 - [Symbol.iterator](): Iterator<PrivateKey> { 139 - return this.keys[Symbol.iterator](); 140 - } 141 - }
-47
packages/oauth/node-client/lib/keyset/types.ts
··· 1 - import type { JWK } from 'jose'; 2 - 3 - /** 4 - * signing algorithms supported by AT Protocol OAuth. 5 - * 6 - * @see {@link https://atproto.com/specs/oauth#confidential-client-authentication} 7 - */ 8 - export type SigningAlgorithm = 9 - | 'ES256' 10 - | 'ES384' 11 - | 'ES512' // EC (ES256 is spec minimum) 12 - | 'PS256' 13 - | 'PS384' 14 - | 'PS512' // RSA-PSS 15 - | 'RS256' 16 - | 'RS384' 17 - | 'RS512'; // RSA 18 - 19 - /** 20 - * private key for client authentication via `private_key_jwt`. 21 - */ 22 - export interface PrivateKey { 23 - /** key ID, required for `private_key_jwt` */ 24 - kid: string; 25 - /** signing algorithm */ 26 - alg: SigningAlgorithm; 27 - /** imported key object for signing */ 28 - key: CryptoKey; 29 - /** pre-computed public JWK for JWKS export */ 30 - publicJwk: JWK; 31 - } 32 - 33 - /** options for importing a private key */ 34 - export interface ImportKeyOptions { 35 - /** override or provide key ID */ 36 - kid?: string; 37 - /** override or provide algorithm */ 38 - alg?: SigningAlgorithm; 39 - } 40 - 41 - /** criteria for finding a key in a keyset */ 42 - export interface KeySearchOptions { 43 - /** find by specific key ID */ 44 - kid?: string; 45 - /** find by algorithm (single or array of acceptable algs) */ 46 - alg?: string | readonly string[]; 47 - }
+6 -4
packages/oauth/node-client/lib/oauth-client-auth.ts
··· 1 1 import { SignJWT } from 'jose'; 2 2 import { nanoid } from 'nanoid'; 3 3 4 - import { CLIENT_ASSERTION_TYPE_JWT_BEARER, FALLBACK_ALG } from './constants.js'; 5 - import type { Keyset } from './keyset/keyset.js'; 6 - import type { PrivateKey } from './keyset/types.js'; 7 - import type { OAuthAuthorizationServerMetadata } from './schemas/oauth-authorization-server-metadata.js'; 4 + import { 5 + CLIENT_ASSERTION_TYPE_JWT_BEARER, 6 + FALLBACK_ALG, 7 + type OAuthAuthorizationServerMetadata, 8 + } from '@atcute/oauth-types'; 9 + import type { Keyset, PrivateKey } from '@atcute/oauth-keyset'; 8 10 9 11 export { CLIENT_ASSERTION_TYPE_JWT_BEARER }; 10 12
+8 -7
packages/oauth/node-client/lib/oauth-client.ts
··· 3 3 4 4 import type { ActorResolver } from '@atcute/identity-resolver'; 5 5 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 6 + import { 7 + buildClientMetadata, 8 + FALLBACK_ALG, 9 + type ConfidentialClientMetadata, 10 + type OAuthClientMetadata, 11 + type OAuthResponseMode, 12 + } from '@atcute/oauth-types'; 13 + import { Keyset, type PrivateKey } from '@atcute/oauth-keyset'; 6 14 7 - import { buildClientMetadata } from './build-client-metadata.js'; 8 - import { FALLBACK_ALG } from './constants.js'; 9 15 import type { DpopNonceCache } from './dpop/fetch-dpop.js'; 10 16 import { generateDpopKey } from './dpop/generate-key.js'; 11 17 import { OAuthCallbackError, TokenRevokedError } from './errors.js'; 12 - import { Keyset } from './keyset/keyset.js'; 13 - import type { PrivateKey } from './keyset/types.js'; 14 18 import { OAuthServerAgent } from './oauth-server-agent.js'; 15 19 import { OAuthServerFactory } from './oauth-server-factory.js'; 16 20 import { OAuthSession } from './oauth-session.js'; ··· 24 28 ProtectedResourceMetadataResolver, 25 29 type ProtectedResourceMetadataCache, 26 30 } from './resolvers/protected-resource-metadata.js'; 27 - import type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js'; 28 - import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 29 - import type { OAuthResponseMode } from './schemas/oauth-response-mode.js'; 30 31 import { SessionGetter, type SessionEventListener } from './session-getter.js'; 31 32 import type { SessionStore } from './types/sessions.js'; 32 33 import type { StateStore, StoredState } from './types/states.js';
+2 -3
packages/oauth/node-client/lib/oauth-server-agent.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 3 3 import type { Did } from '@atcute/lexicons'; 4 + import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types'; 5 + import { generatePrivateKey, Keyset } from '@atcute/oauth-keyset'; 4 6 5 7 import { generateDpopKey } from './dpop/generate-key.js'; 6 8 import { OAuthResponseError, TokenRefreshError } from './errors.js'; 7 - import { generatePrivateKey } from './keyset/import-key.js'; 8 - import { Keyset } from './keyset/keyset.js'; 9 9 import { OAuthServerAgent, type OAuthServerAgentOptions } from './oauth-server-agent.js'; 10 10 import type { OAuthResolver } from './resolvers/index.js'; 11 - import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 12 11 import { MemoryStore } from './utils/memory-store.js'; 13 12 14 13 const createMockMetadata = (): AtprotoAuthorizationServerMetadata => ({
+9 -8
packages/oauth/node-client/lib/oauth-server-agent.ts
··· 1 1 import type { JWK } from 'jose'; 2 2 3 3 import type { Did } from '@atcute/lexicons'; 4 + import { 5 + atprotoOAuthTokenResponseSchema, 6 + oauthParResponseSchema, 7 + type AtprotoAuthorizationServerMetadata, 8 + type AtprotoOAuthTokenResponse, 9 + type OAuthClientMetadata, 10 + type OAuthParResponse, 11 + } from '@atcute/oauth-types'; 12 + import type { Keyset } from '@atcute/oauth-keyset'; 4 13 import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 5 14 6 15 import { JSON_MIME, PAR_RESPONSE_MAX_SIZE, TOKEN_RESPONSE_MAX_SIZE } from './constants.js'; 7 16 import { createDpopFetch } from './dpop/fetch-dpop.js'; 8 17 import { OAuthResponseError, TokenRefreshError } from './errors.js'; 9 - import { Keyset } from './keyset/keyset.js'; 10 18 import { 11 19 createClientAssertionFactory, 12 20 type ClientAuthMethod, 13 21 type ClientCredentialsFactory, 14 22 } from './oauth-client-auth.js'; 15 23 import { OAuthResolver } from './resolvers/index.js'; 16 - import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 17 - import { 18 - atprotoOAuthTokenResponseSchema, 19 - type AtprotoOAuthTokenResponse, 20 - } from './schemas/atproto-oauth-token-response.js'; 21 - import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 22 - import { oauthParResponseSchema, type OAuthParResponse } from './schemas/oauth-par-response.js'; 23 24 import type { TokenSet } from './types/token-set.js'; 24 25 import type { Store } from './utils/store.js'; 25 26
+3 -3
packages/oauth/node-client/lib/oauth-server-factory.ts
··· 1 1 import type { JWK } from 'jose'; 2 2 3 - import { Keyset } from './keyset/keyset.js'; 3 + import type { AtprotoAuthorizationServerMetadata, OAuthClientMetadata } from '@atcute/oauth-types'; 4 + import type { Keyset } from '@atcute/oauth-keyset'; 5 + 4 6 import { type ClientAuthMethod, negotiateClientAuth } from './oauth-client-auth.js'; 5 7 import { OAuthServerAgent } from './oauth-server-agent.js'; 6 8 import { OAuthResolver } from './resolvers/index.js'; 7 - import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js'; 8 - import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 9 9 import type { Store } from './utils/store.js'; 10 10 11 11 export interface OAuthServerFactoryOptions {
+1 -1
packages/oauth/node-client/lib/oauth-session.ts
··· 1 1 import type { FetchHandlerObject } from '@atcute/client'; 2 2 import type { Did } from '@atcute/lexicons'; 3 + import type { AtprotoOAuthScope } from '@atcute/oauth-types'; 3 4 4 5 import { createDpopFetch } from './dpop/fetch-dpop.js'; 5 6 import { TokenInvalidError, TokenRevokedError } from './errors.js'; 6 7 import type { OAuthServerAgent } from './oauth-server-agent.js'; 7 - import type { AtprotoOAuthScope } from './schemas/atproto-oauth-scope.js'; 8 8 import type { SessionGetter } from './session-getter.js'; 9 9 import type { TokenSet } from './types/token-set.js'; 10 10
+2 -1
packages/oauth/node-client/lib/resolvers/authorization-server-metadata.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 3 - import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js'; 3 + import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types'; 4 + 4 5 import { MemoryStore } from '../utils/memory-store.js'; 5 6 6 7 import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js';
+5 -5
packages/oauth/node-client/lib/resolvers/authorization-server-metadata.ts
··· 1 + import { 2 + atprotoAuthorizationServerMetadataValidator, 3 + oauthIssuerIdentifierSchema, 4 + type AtprotoAuthorizationServerMetadata, 5 + } from '@atcute/oauth-types'; 1 6 import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 2 7 3 8 import { AS_METADATA_MAX_SIZE, JSON_MIME } from '../constants.js'; 4 9 import { OAuthResolverError } from '../errors.js'; 5 - import { 6 - atprotoAuthorizationServerMetadataValidator, 7 - type AtprotoAuthorizationServerMetadata, 8 - } from '../schemas/atproto-authorization-server-metadata.js'; 9 - import { oauthIssuerIdentifierSchema } from '../schemas/oauth-issuer-identifier.js'; 10 10 import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js'; 11 11 import type { Store } from '../utils/store.js'; 12 12
+1 -1
packages/oauth/node-client/lib/resolvers/index.ts
··· 1 1 import type { ActorResolver, ResolvedActor } from '@atcute/identity-resolver'; 2 2 import type { ActorIdentifier } from '@atcute/lexicons'; 3 + import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types'; 3 4 4 5 import { OAuthResolverError } from '../errors.js'; 5 - import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js'; 6 6 7 7 import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js'; 8 8 import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js';
+2 -1
packages/oauth/node-client/lib/resolvers/protected-resource-metadata.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 3 - import type { AtprotoProtectedResourceMetadata } from '../schemas/atproto-protected-resource-metadata.js'; 3 + import type { AtprotoProtectedResourceMetadata } from '@atcute/oauth-types'; 4 + 4 5 import { MemoryStore } from '../utils/memory-store.js'; 5 6 6 7 import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js';
+4 -4
packages/oauth/node-client/lib/resolvers/protected-resource-metadata.ts
··· 1 + import { 2 + atprotoProtectedResourceMetadataValidator, 3 + type AtprotoProtectedResourceMetadata, 4 + } from '@atcute/oauth-types'; 1 5 import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 2 6 3 7 import { JSON_MIME, PR_METADATA_MAX_SIZE } from '../constants.js'; 4 8 import { OAuthResolverError } from '../errors.js'; 5 - import { 6 - atprotoProtectedResourceMetadataValidator, 7 - type AtprotoProtectedResourceMetadata, 8 - } from '../schemas/atproto-protected-resource-metadata.js'; 9 9 import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js'; 10 10 import type { Store } from '../utils/store.js'; 11 11
-139
packages/oauth/node-client/lib/schemas/atcute-confidential-client-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { atprotoOAuthScopeSchema } from './atproto-oauth-scope.js'; 4 - import { oauthClientIdDiscoverableSchema } from './oauth-client-id-discoverable.js'; 5 - import { httpsUriSchema, nonLocalWebUriSchema, webUriSchema } from './uri.js'; 6 - import { isLocalHostname } from './utils.js'; 7 - 8 - const SINGLE_SCOPE_RE = /^[\x21\x23-\x5B\x5D-\x7E]+$/; 9 - 10 - const singleScopeSchema = v.string().assert((input) => SINGLE_SCOPE_RE.test(input), `invalid OAuth scope`); 11 - 12 - /** 13 - * user-facing client metadata for configuring a confidential OAuth client. 14 - * 15 - * this is a lean subset of OAuth client metadata, focused on what you actually provide. 16 - * the library will fill in atproto-required values like `dpop_bound_access_tokens`, 17 - * `token_endpoint_auth_method`, and default `grant_types` / `response_types`. 18 - */ 19 - export const confidentialClientMetadataSchema = v 20 - .object({ 21 - /** discoverable https client_id URL (where metadata is hosted) */ 22 - client_id: oauthClientIdDiscoverableSchema, 23 - 24 - /** redirect URIs for authorization responses (must be https) */ 25 - redirect_uris: v 26 - .array(httpsUriSchema) 27 - .assert((arr) => arr.length > 0, `must have at least one redirect URI`) 28 - .assert((arr) => { 29 - for (const uri of arr) { 30 - const url = new URL(uri); 31 - if (url.username || url.password) { 32 - return false; 33 - } 34 - } 35 - return true; 36 - }, `redirect URIs must not contain credentials`), 37 - 38 - /** 39 - * OAuth scope - either: 40 - * - a space-separated string (must include "atproto") 41 - * - an array of scope strings ('atproto' is added automatically) 42 - */ 43 - scope: v.union( 44 - atprotoOAuthScopeSchema.chain((input) => { 45 - const scopes = input.split(/\s+/); 46 - 47 - for (let i = 0, len = scopes.length; i < len; i++) { 48 - const aka = scopes[i]; 49 - 50 - for (let j = 0; j < i; j++) { 51 - if (aka === scopes[j]) { 52 - return v.err(`duplicate "${aka}" scope`); 53 - } 54 - } 55 - } 56 - 57 - return v.ok(input); 58 - }), 59 - v.array(singleScopeSchema).chain((input) => { 60 - if (!input.includes('atproto')) { 61 - input = ['atproto', ...input]; 62 - } 63 - 64 - for (let i = 0, len = input.length; i < len; i++) { 65 - const aka = input[i]; 66 - 67 - for (let j = 0; j < i; j++) { 68 - if (aka === input[j]) { 69 - return v.err(`duplicate "${aka}" scope`); 70 - } 71 - } 72 - } 73 - 74 - return v.ok(input); 75 - }), 76 - ), 77 - 78 - /** optional client homepage */ 79 - client_uri: webUriSchema.optional(), 80 - /** optional display name */ 81 - client_name: v.string().optional(), 82 - /** optional policy url */ 83 - policy_uri: nonLocalWebUriSchema.optional(), 84 - /** optional terms of service url */ 85 - tos_uri: nonLocalWebUriSchema.optional(), 86 - /** optional logo url */ 87 - logo_uri: nonLocalWebUriSchema.optional(), 88 - 89 - /** optional JWKS URL; if omitted, the library will inline jwks from the keyset */ 90 - jwks_uri: httpsUriSchema.optional(), 91 - }) 92 - .chain((input) => { 93 - const clientIdUrl = new URL(input.client_id); 94 - if (isLocalHostname(clientIdUrl.hostname)) { 95 - return v.err({ message: `client_id hostname is invalid`, path: ['client_id'] }); 96 - } 97 - 98 - if (input.jwks_uri) { 99 - const jwksUrl = new URL(input.jwks_uri); 100 - 101 - if (jwksUrl.username || jwksUrl.password) { 102 - return v.err({ message: `jwks_uri must not contain credentials`, path: ['jwks_uri'] }); 103 - } 104 - 105 - if (isLocalHostname(jwksUrl.hostname)) { 106 - return v.err({ message: `jwks_uri hostname is invalid`, path: ['jwks_uri'] }); 107 - } 108 - } 109 - 110 - // for discoverable clients, client_uri (if provided) must be same-origin parent of client_id 111 - if (input.client_uri) { 112 - const clientUriUrl = new URL(input.client_uri); 113 - 114 - if (isLocalHostname(clientUriUrl.hostname)) { 115 - return v.err({ message: `client_uri hostname is invalid`, path: ['client_uri'] }); 116 - } 117 - 118 - if (clientUriUrl.origin !== clientIdUrl.origin) { 119 - return v.err({ 120 - message: `client_uri must have the same origin as the client_id`, 121 - path: ['client_uri'], 122 - }); 123 - } 124 - 125 - if (clientIdUrl.pathname !== clientUriUrl.pathname) { 126 - const prefix = clientUriUrl.pathname.endsWith('/') 127 - ? clientUriUrl.pathname 128 - : `${clientUriUrl.pathname}/`; 129 - 130 - if (!clientIdUrl.pathname.startsWith(prefix)) { 131 - return v.err({ message: `client_uri must be a parent URL of the client_id`, path: ['client_uri'] }); 132 - } 133 - } 134 - } 135 - 136 - return v.ok(input); 137 - }); 138 - 139 - export type ConfidentialClientMetadata = v.Infer<typeof confidentialClientMetadataSchema>;
-32
packages/oauth/node-client/lib/schemas/atproto-authorization-server-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthAuthorizationServerMetadataValidator } from './oauth-authorization-server-metadata.js'; 4 - 5 - /** 6 - * AT Protocol authorization server metadata with required fields and assertions. 7 - * 8 - * @see {@link https://atproto.com/specs/oauth} 9 - */ 10 - export const atprotoAuthorizationServerMetadataValidator = oauthAuthorizationServerMetadataValidator.chain( 11 - (data) => { 12 - // atproto requires client_id_metadata_document support 13 - if (data.client_id_metadata_document_supported !== true) { 14 - return v.err({ 15 - message: `atproto requires client_id_metadata_document_supported to be true`, 16 - path: ['client_id_metadata_document_supported'], 17 - }); 18 - } 19 - 20 - // atproto requires PAR 21 - if (!data.pushed_authorization_request_endpoint) { 22 - return v.err({ 23 - message: `atproto requires pushed_authorization_request_endpoint to be true`, 24 - path: ['pushed_authorization_request_endpoint'], 25 - }); 26 - } 27 - 28 - return v.ok(data as typeof data & { pushed_authorization_request_endpoint: string }); 29 - }, 30 - ); 31 - 32 - export type AtprotoAuthorizationServerMetadata = v.Infer<typeof atprotoAuthorizationServerMetadataValidator>;
-18
packages/oauth/node-client/lib/schemas/atproto-oauth-scope.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { isOAuthScope } from './oauth-scope.js'; 4 - import { isSpaceSeparatedValue } from './utils.js'; 5 - 6 - export const ATPROTO_SCOPE_VALUE = 'atproto'; 7 - 8 - const isAtprotoOAuthScope = (input: string): boolean => { 9 - return isOAuthScope(input) && isSpaceSeparatedValue(ATPROTO_SCOPE_VALUE, input); 10 - }; 11 - 12 - /** atproto OAuth scope (must include "atproto") */ 13 - export const atprotoOAuthScopeSchema = v.string().assert(isAtprotoOAuthScope, `invalid atproto OAuth scope`); 14 - 15 - export type AtprotoOAuthScope = v.Infer<typeof atprotoOAuthScopeSchema>; 16 - 17 - /** default scope is for reading identity (did) only */ 18 - export const DEFAULT_ATPROTO_OAUTH_SCOPE: AtprotoOAuthScope = ATPROTO_SCOPE_VALUE;
-20
packages/oauth/node-client/lib/schemas/atproto-oauth-token-response.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { isAtprotoDid } from '@atcute/identity'; 4 - 5 - import { atprotoOAuthScopeSchema } from './atproto-oauth-scope.js'; 6 - import { oauthAuthorizationDetailsSchema } from './oauth-authorization-details.js'; 7 - 8 - export const atprotoOAuthTokenResponseSchema = v.object({ 9 - access_token: v.string(), 10 - token_type: v.literal('DPoP'), 11 - sub: v.string().assert(isAtprotoDid, `must be a did:plc or did:web`), 12 - scope: atprotoOAuthScopeSchema, 13 - refresh_token: v.string().optional(), 14 - expires_in: v.number().optional(), 15 - // https://datatracker.ietf.org/doc/html/rfc9396#name-enriched-authorization-deta 16 - authorization_details: oauthAuthorizationDetailsSchema.optional(), 17 - // OpenID is not compatible with atproto identities 18 - }); 19 - 20 - export type AtprotoOAuthTokenResponse = v.Infer<typeof atprotoOAuthTokenResponseSchema>;
-24
packages/oauth/node-client/lib/schemas/atproto-protected-resource-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthProtectedResourceMetadataValidator } from './oauth-protected-resource-metadata.js'; 4 - 5 - /** 6 - * AT Protocol protected resource metadata with required fields. 7 - * 8 - * @see {@link https://atproto.com/specs/oauth} 9 - */ 10 - export const atprotoProtectedResourceMetadataValidator = oauthProtectedResourceMetadataValidator.chain( 11 - (data) => { 12 - // atproto requires exactly one authorization server 13 - if (data.authorization_servers?.length !== 1) { 14 - return v.err({ 15 - message: `atproto requires exactly one authorization server`, 16 - path: ['authorization_servers'], 17 - }); 18 - } 19 - 20 - return v.ok(data as typeof data & { authorization_servers: [string] }); 21 - }, 22 - ); 23 - 24 - export type AtprotoProtectedResourceMetadata = v.Infer<typeof atprotoProtectedResourceMetadataValidator>;
-189
packages/oauth/node-client/lib/schemas/jwk.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { isLastOccurrence } from './utils.js'; 4 - 5 - // key usage constants 6 - const PUBLIC_KEY_USAGE = ['verify', 'encrypt', 'wrapKey'] as const; 7 - const PRIVATE_KEY_USAGE = ['sign', 'decrypt', 'unwrapKey', 'deriveKey', 'deriveBits'] as const; 8 - const KEY_USAGE = [...PRIVATE_KEY_USAGE, ...PUBLIC_KEY_USAGE] as const; 9 - 10 - type InternalKeyUsage = (typeof KEY_USAGE)[number]; 11 - 12 - const isPublicKeyUsage = (usage: unknown): usage is (typeof PUBLIC_KEY_USAGE)[number] => { 13 - return (PUBLIC_KEY_USAGE as readonly unknown[]).includes(usage); 14 - }; 15 - 16 - const isPrivateKeyUsage = (usage: unknown): usage is (typeof PRIVATE_KEY_USAGE)[number] => { 17 - return (PRIVATE_KEY_USAGE as readonly unknown[]).includes(usage); 18 - }; 19 - 20 - const isSigKeyUsage = (v: InternalKeyUsage): boolean => v === 'verify'; 21 - const isEncKeyUsage = (v: InternalKeyUsage): boolean => v === 'encrypt' || v === 'wrapKey'; 22 - 23 - export const keyUsageSchema = v.union( 24 - v.literal('verify'), 25 - v.literal('encrypt'), 26 - v.literal('wrapKey'), 27 - v.literal('sign'), 28 - v.literal('decrypt'), 29 - v.literal('unwrapKey'), 30 - v.literal('deriveKey'), 31 - v.literal('deriveBits'), 32 - ); 33 - 34 - export const publicKeyUsageSchema = v.union(v.literal('verify'), v.literal('encrypt'), v.literal('wrapKey')); 35 - 36 - const jwkBaseSchema = v.object({ 37 - kty: v.string(), 38 - alg: v.string().optional(), 39 - kid: v.string().optional(), 40 - use: v.union(v.literal('sig'), v.literal('enc')).optional(), 41 - key_ops: v.array(keyUsageSchema).optional(), 42 - 43 - // X.509 44 - x5c: v.array(v.string()).optional(), 45 - x5t: v.string().optional(), 46 - 'x5t#S256': v.string().optional(), 47 - x5u: v.string().optional(), 48 - 49 - // WebCrypto 50 - ext: v.boolean().optional(), 51 - 52 - // Federation Historical Keys Response 53 - iat: v.number().optional(), 54 - exp: v.number().optional(), 55 - nbf: v.number().optional(), 56 - revoked: v 57 - .object({ 58 - revoked_at: v.number(), 59 - reason: v.string().optional(), 60 - }) 61 - .optional(), 62 - }); 63 - 64 - const jwkRsaKeySchema = jwkBaseSchema.extend({ 65 - kty: v.literal('RSA'), 66 - alg: v 67 - .union( 68 - v.literal('RS256'), 69 - v.literal('RS384'), 70 - v.literal('RS512'), 71 - v.literal('PS256'), 72 - v.literal('PS384'), 73 - v.literal('PS512'), 74 - ) 75 - .optional(), 76 - n: v.string(), 77 - e: v.string(), 78 - d: v.string().optional(), 79 - p: v.string().optional(), 80 - q: v.string().optional(), 81 - dp: v.string().optional(), 82 - dq: v.string().optional(), 83 - qi: v.string().optional(), 84 - oth: v 85 - .array( 86 - v.object({ 87 - r: v.string().optional(), 88 - d: v.string().optional(), 89 - t: v.string().optional(), 90 - }), 91 - ) 92 - .optional(), 93 - }); 94 - 95 - const jwkEcKeySchema = jwkBaseSchema.extend({ 96 - kty: v.literal('EC'), 97 - alg: v.union(v.literal('ES256'), v.literal('ES384'), v.literal('ES512')).optional(), 98 - crv: v.union(v.literal('P-256'), v.literal('P-384'), v.literal('P-521')), 99 - x: v.string(), 100 - y: v.string(), 101 - d: v.string().optional(), 102 - }); 103 - 104 - const jwkEcSecp256k1KeySchema = jwkBaseSchema.extend({ 105 - kty: v.literal('EC'), 106 - alg: v.literal('ES256K').optional(), 107 - crv: v.literal('secp256k1'), 108 - x: v.string(), 109 - y: v.string(), 110 - d: v.string().optional(), 111 - }); 112 - 113 - const jwkOkpKeySchema = jwkBaseSchema.extend({ 114 - kty: v.literal('OKP'), 115 - alg: v.literal('EdDSA').optional(), 116 - crv: v.union(v.literal('Ed25519'), v.literal('Ed448')), 117 - x: v.string(), 118 - d: v.string().optional(), 119 - }); 120 - 121 - const jwkSymKeySchema = jwkBaseSchema.extend({ 122 - kty: v.literal('oct'), 123 - alg: v.union(v.literal('HS256'), v.literal('HS384'), v.literal('HS512')).optional(), 124 - k: v.string(), 125 - }); 126 - 127 - const hasPrivateSecret = <J extends object>(jwk: J): boolean => { 128 - return ('d' in jwk && jwk.d != null) || ('k' in jwk && jwk.k != null); 129 - }; 130 - 131 - const isPublicJwk = <J extends object>(jwk: J): boolean => { 132 - return !hasPrivateSecret(jwk); 133 - }; 134 - 135 - /** JWK schema for known key types */ 136 - export const jwkSchema = v 137 - .union(jwkRsaKeySchema, jwkEcKeySchema, jwkEcSecp256k1KeySchema, jwkOkpKeySchema, jwkSymKeySchema) 138 - .chain((k) => { 139 - // "use" can only be used with public keys 140 - if (k.use != null && !isPublicJwk(k)) { 141 - return v.err({ message: `"use" can only be used with public keys`, path: ['use'] }); 142 - } 143 - 144 - // private key usage not allowed for public keys 145 - if (k.key_ops?.some(isPrivateKeyUsage) && isPublicJwk(k)) { 146 - return v.err({ message: `private key usage not allowed for public keys`, path: ['key_ops'] }); 147 - } 148 - 149 - // key_ops must not contain duplicates 150 - if (k.key_ops && !k.key_ops.every(isLastOccurrence)) { 151 - return v.err({ message: `key_ops must not contain duplicates`, path: ['key_ops'] }); 152 - } 153 - 154 - // "use" and "key_ops" must be consistent 155 - if (k.use != null && k.key_ops != null) { 156 - const consistent = 157 - (k.use === 'sig' && k.key_ops.every(isSigKeyUsage)) || 158 - (k.use === 'enc' && k.key_ops.every(isEncKeyUsage)); 159 - if (!consistent) { 160 - return v.err({ message: `"key_ops" must be consistent with "use"`, path: ['key_ops'] }); 161 - } 162 - } 163 - 164 - return v.ok(k); 165 - }); 166 - 167 - /** public JWK schema (kid required, no private keys) */ 168 - export const jwkPubSchema = jwkSchema.chain((k) => { 169 - if (k.kid == null) { 170 - return v.err({ message: `"kid" is required`, path: ['kid'] }); 171 - } 172 - 173 - if (!isPublicJwk(k)) { 174 - return v.err({ message: `private key not allowed` }); 175 - } 176 - 177 - if (k.key_ops && !k.key_ops.every(isPublicKeyUsage)) { 178 - return v.err({ 179 - message: `"key_ops" must not contain private key usage for public keys`, 180 - path: ['key_ops'], 181 - }); 182 - } 183 - 184 - return v.ok(k); 185 - }); 186 - 187 - export type KeyUsage = v.Infer<typeof keyUsageSchema>; 188 - export type Jwk = v.Infer<typeof jwkSchema>; 189 - export type JwkPub = v.Infer<typeof jwkPubSchema>;
-45
packages/oauth/node-client/lib/schemas/jwks.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { jwkPubSchema, jwkSchema, type Jwk, type JwkPub } from './jwk.js'; 4 - 5 - /** JWKS (JSON Web Key Set) */ 6 - export const jwksSchema = v.object({ 7 - keys: v.array(v.unknown()).chain((input, options) => { 8 - // implementations SHOULD ignore JWKs within a JWK Set that use "kty" 9 - // values that are not understood, are missing required members, or 10 - // have values out of the supported ranges. 11 - const keys: Jwk[] = []; 12 - 13 - for (const item of input) { 14 - const result = jwkSchema.try(item, options); 15 - if (!result.ok) { 16 - continue; 17 - } 18 - 19 - keys.push(result.value); 20 - } 21 - 22 - return v.ok(keys); 23 - }), 24 - }); 25 - 26 - /** public JWKS (JSON Web Key Set with only public keys) */ 27 - export const jwksPubSchema = v.object({ 28 - keys: v.array(v.unknown()).chain((input, options) => { 29 - const keys: JwkPub[] = []; 30 - 31 - for (const item of input) { 32 - const result = jwkPubSchema.try(item, options); 33 - if (!result.ok) { 34 - continue; 35 - } 36 - 37 - keys.push(result.value); 38 - } 39 - 40 - return v.ok(keys); 41 - }), 42 - }); 43 - 44 - export type Jwks = v.Infer<typeof jwksSchema>; 45 - export type JwksPub = v.Infer<typeof jwksPubSchema>;
-43
packages/oauth/node-client/lib/schemas/oauth-authorization-details.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { urlSchema } from './uri.js'; 4 - 5 - /** 6 - * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2} 7 - */ 8 - export const oauthAuthorizationDetailSchema = v.object({ 9 - type: v.string(), 10 - /** 11 - * an array of strings representing the location of the resource or RS. these 12 - * strings are typically URIs identifying the location of the RS. 13 - */ 14 - locations: v.array(urlSchema).optional(), 15 - /** 16 - * an array of strings representing the kinds of actions to be taken at the 17 - * resource. 18 - */ 19 - actions: v.array(v.string()).optional(), 20 - /** 21 - * an array of strings representing the kinds of data being requested from the 22 - * resource. 23 - */ 24 - datatypes: v.array(v.string()).optional(), 25 - /** 26 - * a string identifier indicating a specific resource available at the API. 27 - */ 28 - identifier: v.string().optional(), 29 - /** 30 - * an array of strings representing the types or levels of privilege being 31 - * requested at the resource. 32 - */ 33 - privileges: v.array(v.string()).optional(), 34 - }); 35 - 36 - export type OAuthAuthorizationDetail = v.Infer<typeof oauthAuthorizationDetailSchema>; 37 - 38 - /** 39 - * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2} 40 - */ 41 - export const oauthAuthorizationDetailsSchema = v.array(oauthAuthorizationDetailSchema); 42 - 43 - export type OAuthAuthorizationDetails = v.Infer<typeof oauthAuthorizationDetailsSchema>;
-99
packages/oauth/node-client/lib/schemas/oauth-authorization-server-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthCodeChallengeMethodSchema } from './oauth-code-challenge-method.js'; 4 - import { oauthIssuerIdentifierSchema } from './oauth-issuer-identifier.js'; 5 - import { webUriSchema } from './uri.js'; 6 - 7 - /** 8 - * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} 9 - */ 10 - export const oauthAuthorizationServerMetadataSchema = v.object({ 11 - issuer: oauthIssuerIdentifierSchema, 12 - 13 - claims_supported: v.array(v.string()).optional(), 14 - claims_locales_supported: v.array(v.string()).optional(), 15 - claims_parameter_supported: v.boolean().optional(), 16 - request_parameter_supported: v.boolean().optional(), 17 - request_uri_parameter_supported: v.boolean().optional(), 18 - require_request_uri_registration: v.boolean().optional(), 19 - scopes_supported: v.array(v.string()).optional(), 20 - subject_types_supported: v.array(v.string()).optional(), 21 - response_types_supported: v.array(v.string()).optional(), 22 - response_modes_supported: v.array(v.string()).optional(), 23 - grant_types_supported: v.array(v.string()).optional(), 24 - code_challenge_methods_supported: v.array(oauthCodeChallengeMethodSchema).optional(), 25 - ui_locales_supported: v.array(v.string()).optional(), 26 - id_token_signing_alg_values_supported: v.array(v.string()).optional(), 27 - display_values_supported: v.array(v.string()).optional(), 28 - request_object_signing_alg_values_supported: v.array(v.string()).optional(), 29 - authorization_response_iss_parameter_supported: v.boolean().optional(), 30 - authorization_details_types_supported: v.array(v.string()).optional(), 31 - request_object_encryption_alg_values_supported: v.array(v.string()).optional(), 32 - request_object_encryption_enc_values_supported: v.array(v.string()).optional(), 33 - 34 - jwks_uri: webUriSchema.optional(), 35 - 36 - authorization_endpoint: webUriSchema, 37 - 38 - token_endpoint: webUriSchema, 39 - // https://www.rfc-editor.org/rfc/rfc8414.html#section-2 40 - token_endpoint_auth_methods_supported: v.array(v.string()).optional(), 41 - token_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 42 - 43 - revocation_endpoint: webUriSchema.optional(), 44 - revocation_endpoint_auth_methods_supported: v.array(v.string()).optional(), 45 - revocation_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 46 - 47 - introspection_endpoint: webUriSchema.optional(), 48 - introspection_endpoint_auth_methods_supported: v.array(v.string()).optional(), 49 - introspection_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 50 - 51 - pushed_authorization_request_endpoint: webUriSchema.optional(), 52 - pushed_authorization_request_endpoint_auth_methods_supported: v.array(v.string()).optional(), 53 - pushed_authorization_request_endpoint_auth_signing_alg_values_supported: v.array(v.string()).optional(), 54 - require_pushed_authorization_requests: v.boolean().optional(), 55 - 56 - userinfo_endpoint: webUriSchema.optional(), 57 - end_session_endpoint: webUriSchema.optional(), 58 - registration_endpoint: webUriSchema.optional(), 59 - 60 - // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 61 - dpop_signing_alg_values_supported: v.array(v.string()).optional(), 62 - 63 - // https://www.rfc-editor.org/rfc/rfc9728.html#section-4 64 - protected_resources: v.array(webUriSchema).optional(), 65 - 66 - // https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html 67 - client_id_metadata_document_supported: v.boolean().optional(), 68 - }); 69 - 70 - export type OAuthAuthorizationServerMetadata = v.Infer<typeof oauthAuthorizationServerMetadataSchema>; 71 - 72 - export const oauthAuthorizationServerMetadataValidator = oauthAuthorizationServerMetadataSchema.chain( 73 - (data) => { 74 - if (data.require_pushed_authorization_requests && !data.pushed_authorization_request_endpoint) { 75 - return v.err({ 76 - message: `"pushed_authorization_request_endpoint" required when "require_pushed_authorization_requests" is true`, 77 - path: ['pushed_authorization_request_endpoint'], 78 - }); 79 - } 80 - 81 - if (data.response_types_supported && !data.response_types_supported.includes('code')) { 82 - return v.err({ 83 - message: `response type "code" is required`, 84 - path: ['response_types_supported'], 85 - }); 86 - } 87 - 88 - if (data.token_endpoint_auth_signing_alg_values_supported?.includes('none')) { 89 - // https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 90 - // > The value `none` MUST NOT be used. 91 - return v.err({ 92 - message: `client authentication method "none" is not allowed`, 93 - path: ['token_endpoint_auth_signing_alg_values_supported'], 94 - }); 95 - } 96 - 97 - return v.ok(data); 98 - }, 99 - );
-53
packages/oauth/node-client/lib/schemas/oauth-client-id-discoverable.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthClientIdSchema } from './oauth-client-id.js'; 4 - import { httpsUriSchema } from './uri.js'; 5 - import { extractUrlPath, isHostnameIP } from './utils.js'; 6 - 7 - /** 8 - * @see {@link https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html} 9 - */ 10 - export const oauthClientIdDiscoverableSchema = v.string().chain((input, options) => { 11 - // first validate as base client ID 12 - const clientIdResult = oauthClientIdSchema.try(input, options); 13 - if (!clientIdResult.ok) { 14 - return clientIdResult; 15 - } 16 - 17 - // then validate as https URI 18 - const httpsResult = httpsUriSchema.try(input, options); 19 - if (!httpsResult.ok) { 20 - return httpsResult; 21 - } 22 - 23 - const url = new URL(input); 24 - 25 - if (url.username || url.password) { 26 - return v.err(`client ID must not contain credentials`); 27 - } 28 - 29 - if (url.hash) { 30 - return v.err(`client ID must not contain a fragment`); 31 - } 32 - 33 - if (url.pathname === '/') { 34 - return v.err(`client ID must contain a path component (e.g. "/client-metadata.json")`); 35 - } 36 - 37 - if (url.pathname.endsWith('/')) { 38 - return v.err(`client ID path must not end with a trailing slash`); 39 - } 40 - 41 - if (isHostnameIP(url.hostname)) { 42 - return v.err(`client ID hostname must not be an IP address`); 43 - } 44 - 45 - // URL constructor normalizes the URL, so we extract the path manually to 46 - // avoid normalization, then compare it to the normalized path to ensure 47 - // that the URL does not contain path traversal or other unexpected characters 48 - if (extractUrlPath(input) !== url.pathname) { 49 - return v.err(`client ID must be in canonical form ("${url.href}", got "${input}")`); 50 - } 51 - 52 - return v.ok(input); 53 - });
-6
packages/oauth/node-client/lib/schemas/oauth-client-id.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - /** base OAuth client ID (any non-empty string) */ 4 - export const oauthClientIdSchema = v.string().assert((input) => input.length > 0, `must not be empty`); 5 - 6 - export type OAuthClientId = v.Infer<typeof oauthClientIdSchema>;
-83
packages/oauth/node-client/lib/schemas/oauth-client-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { jwksPubSchema } from './jwks.js'; 4 - import { oauthClientIdSchema } from './oauth-client-id.js'; 5 - import { oauthEndpointAuthMethodSchema } from './oauth-endpoint-auth-method.js'; 6 - import { oauthGrantTypeSchema } from './oauth-grant-type.js'; 7 - import { oauthRedirectUriSchema } from './oauth-redirect-uri.js'; 8 - import { oauthResponseTypeSchema } from './oauth-response-type.js'; 9 - import { oauthScopeSchema } from './oauth-scope.js'; 10 - import { webUriSchema } from './uri.js'; 11 - 12 - const oauthApplicationTypeSchema = v.union(v.literal('web'), v.literal('native')); 13 - 14 - const oauthSubjectTypeSchema = v.union(v.literal('public'), v.literal('pairwise')); 15 - 16 - // simple email validation 17 - const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 18 - 19 - /** 20 - * base OAuth client metadata schema. 21 - * 22 - * @see {@link https://openid.net/specs/openid-connect-registration-1_0.html} 23 - * @see {@link https://datatracker.ietf.org/doc/html/rfc7591} 24 - */ 25 - export const oauthClientMetadataSchema = v.object({ 26 - // https://www.rfc-editor.org/rfc/rfc7591.html#section-2 27 - redirect_uris: v 28 - .array(oauthRedirectUriSchema) 29 - .assert((arr) => arr.length > 0, `must have at least one redirect URI`), 30 - response_types: v.array(oauthResponseTypeSchema).optional(), 31 - // > If omitted, the default is that the client will use only the "code" 32 - // > response type. 33 - // .optional((): OAuthResponseType[] => ['code']) 34 - grant_types: v.array(oauthGrantTypeSchema).optional(), 35 - // > If omitted, the default behavior is that the client will use only the 36 - // > "authorization_code" Grant Type. 37 - // .optional((): OAuthGrantType[] => ['authorization_code']), 38 - scope: oauthScopeSchema.optional(), 39 - // https://www.rfc-editor.org/rfc/rfc7591.html#section-2 40 - token_endpoint_auth_method: oauthEndpointAuthMethodSchema.optional(), 41 - // > If unspecified or omitted, the default is "client_secret_basic" [...]. 42 - // .optional((): OAuthEndpointAuthMethod => 'client_secret_basic'), 43 - token_endpoint_auth_signing_alg: v.string().optional(), 44 - userinfo_signed_response_alg: v.string().optional(), 45 - userinfo_encrypted_response_alg: v.string().optional(), 46 - jwks_uri: webUriSchema.optional(), 47 - jwks: jwksPubSchema.optional(), 48 - application_type: oauthApplicationTypeSchema.optional(), 49 - // .optional((): OAuthApplicationType => 'web'), 50 - subject_type: oauthSubjectTypeSchema.optional(), 51 - // .optional((): OAuthSubjectType => 'public'), 52 - request_object_signing_alg: v.string().optional(), 53 - id_token_signed_response_alg: v.string().optional(), 54 - authorization_signed_response_alg: v.string().optional(), 55 - authorization_encrypted_response_enc: v.literal('A128CBC-HS256').optional(), 56 - authorization_encrypted_response_alg: v.string().optional(), 57 - client_id: oauthClientIdSchema.optional(), 58 - client_name: v.string().optional(), 59 - client_uri: webUriSchema.optional(), 60 - policy_uri: webUriSchema.optional(), 61 - tos_uri: webUriSchema.optional(), 62 - logo_uri: webUriSchema.optional(), 63 - 64 - /** 65 - * default Maximum Authentication Age. specifies that the End-User MUST be 66 - * actively authenticated if the End-User was authenticated longer ago than 67 - * the specified number of seconds. the max_age request parameter overrides 68 - * this default value. if omitted, no default Maximum Authentication Age is 69 - * specified. 70 - */ 71 - default_max_age: v.number().optional(), 72 - require_auth_time: v.boolean().optional(), 73 - contacts: v.array(v.string().assert((s) => EMAIL_RE.test(s), `must be a valid email`)).optional(), 74 - tls_client_certificate_bound_access_tokens: v.boolean().optional(), 75 - 76 - // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 77 - dpop_bound_access_tokens: v.boolean().optional(), 78 - 79 - // https://datatracker.ietf.org/doc/html/rfc9396#section-14.5 80 - authorization_details_types: v.array(v.string()).optional(), 81 - }); 82 - 83 - export type OAuthClientMetadata = v.Infer<typeof oauthClientMetadataSchema>;
-5
packages/oauth/node-client/lib/schemas/oauth-code-challenge-method.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - export const oauthCodeChallengeMethodSchema = v.union(v.literal('S256'), v.literal('plain')); 4 - 5 - export type OAuthCodeChallengeMethod = v.Infer<typeof oauthCodeChallengeMethodSchema>;
-13
packages/oauth/node-client/lib/schemas/oauth-endpoint-auth-method.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - export const oauthEndpointAuthMethodSchema = v.union( 4 - v.literal('client_secret_basic'), 5 - v.literal('client_secret_jwt'), 6 - v.literal('client_secret_post'), 7 - v.literal('none'), 8 - v.literal('private_key_jwt'), 9 - v.literal('self_signed_tls_client_auth'), 10 - v.literal('tls_client_auth'), 11 - ); 12 - 13 - export type OAuthEndpointAuthMethod = v.Infer<typeof oauthEndpointAuthMethodSchema>;
-13
packages/oauth/node-client/lib/schemas/oauth-grant-type.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - export const oauthGrantTypeSchema = v.union( 4 - v.literal('authorization_code'), 5 - v.literal('implicit'), 6 - v.literal('refresh_token'), 7 - v.literal('password'), // not part of OAuth 2.1 8 - v.literal('client_credentials'), 9 - v.literal('urn:ietf:params:oauth:grant-type:jwt-bearer'), 10 - v.literal('urn:ietf:params:oauth:grant-type:saml2-bearer'), 11 - ); 12 - 13 - export type OAuthGrantType = v.Infer<typeof oauthGrantTypeSchema>;
-30
packages/oauth/node-client/lib/schemas/oauth-issuer-identifier.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { webUriSchema } from './uri.js'; 4 - 5 - export const oauthIssuerIdentifierSchema = webUriSchema.chain((input) => { 6 - // validate the issuer (MIX-UP attacks) 7 - 8 - if (input.endsWith('/')) { 9 - return v.err(`issuer URL must not end with a slash`); 10 - } 11 - 12 - const url = new URL(input); 13 - 14 - if (url.username || url.password) { 15 - return v.err(`issuer URL must not contain a username or password`); 16 - } 17 - 18 - if (url.hash || url.search) { 19 - return v.err(`issuer URL must not contain a query or fragment`); 20 - } 21 - 22 - const canonicalValue = url.pathname === '/' ? url.origin : url.href; 23 - if (input !== canonicalValue) { 24 - return v.err(`issuer URL must be in the canonical form`); 25 - } 26 - 27 - return v.ok(input); 28 - }); 29 - 30 - export type OAuthIssuerIdentifier = v.Infer<typeof oauthIssuerIdentifierSchema>;
-10
packages/oauth/node-client/lib/schemas/oauth-par-response.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - const isPositiveInteger = (n: number): boolean => Number.isInteger(n) && n > 0; 4 - 5 - export const oauthParResponseSchema = v.object({ 6 - request_uri: v.string(), 7 - expires_in: v.number().assert(isPositiveInteger, `must be a positive integer`), 8 - }); 9 - 10 - export type OAuthParResponse = v.Infer<typeof oauthParResponseSchema>;
-89
packages/oauth/node-client/lib/schemas/oauth-protected-resource-metadata.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthIssuerIdentifierSchema } from './oauth-issuer-identifier.js'; 4 - import { webUriSchema } from './uri.js'; 5 - 6 - export const oauthBearerMethodSchema = v.union(v.literal('header'), v.literal('body'), v.literal('query')); 7 - 8 - export type OAuthBearerMethod = v.Infer<typeof oauthBearerMethodSchema>; 9 - 10 - /** 11 - * @see {@link https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2} 12 - */ 13 - export const oauthProtectedResourceMetadataSchema = v.object({ 14 - /** 15 - * REQUIRED. the protected resource's resource identifier, which is a URL that 16 - * uses the https scheme and has no query or fragment components. 17 - */ 18 - resource: webUriSchema, 19 - 20 - /** 21 - * OPTIONAL. JSON array containing a list of OAuth authorization server issuer 22 - * identifiers, as defined in RFC8414, for authorization servers that can be 23 - * used with this protected resource. 24 - */ 25 - authorization_servers: v.array(oauthIssuerIdentifierSchema).optional(), 26 - 27 - /** 28 - * OPTIONAL. URL of the protected resource's JWK Set document. 29 - */ 30 - jwks_uri: webUriSchema.optional(), 31 - 32 - /** 33 - * RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that 34 - * are used in authorization requests to request access to this protected resource. 35 - */ 36 - scopes_supported: v.array(v.string()).optional(), 37 - 38 - /** 39 - * OPTIONAL. JSON array containing a list of the supported methods of sending 40 - * an OAuth 2.0 Bearer Token to the protected resource. 41 - */ 42 - bearer_methods_supported: v.array(oauthBearerMethodSchema).optional(), 43 - 44 - /** 45 - * OPTIONAL. JSON array containing a list of the JWS signing algorithms 46 - * supported by the protected resource for signing resource responses. 47 - */ 48 - resource_signing_alg_values_supported: v.array(v.string()).optional(), 49 - 50 - /** 51 - * OPTIONAL. URL of a page containing human-readable information that 52 - * developers might want or need to know when using the protected resource. 53 - */ 54 - resource_documentation: webUriSchema.optional(), 55 - 56 - /** 57 - * OPTIONAL. URL that the protected resource provides to read about the 58 - * protected resource's requirements on how the client can use the data. 59 - */ 60 - resource_policy_uri: webUriSchema.optional(), 61 - 62 - /** 63 - * OPTIONAL. URL that the protected resource provides to read about the 64 - * protected resource's terms of service. 65 - */ 66 - resource_tos_uri: webUriSchema.optional(), 67 - }); 68 - 69 - export const oauthProtectedResourceMetadataValidator = oauthProtectedResourceMetadataSchema.chain((data) => { 70 - const url = new URL(data.resource); 71 - 72 - if (url.search) { 73 - return v.err({ 74 - message: `resource URL must not contain query parameters`, 75 - path: ['resource'], 76 - }); 77 - } 78 - 79 - if (url.hash) { 80 - return v.err({ 81 - message: `resource URL must not contain a fragment`, 82 - path: ['resource'], 83 - }); 84 - } 85 - 86 - return v.ok(data); 87 - }); 88 - 89 - export type OAuthProtectedResourceMetadata = v.Infer<typeof oauthProtectedResourceMetadataSchema>;
-42
packages/oauth/node-client/lib/schemas/oauth-redirect-uri.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { httpsUriSchema, loopbackUriSchema, privateUseUriSchema } from './uri.js'; 4 - 5 - /** 6 - * this is a loopback URI with the additional restriction that the hostname 7 - * `localhost` is not allowed. 8 - * 9 - * @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-8.3 Loopback Redirect Considerations} RFC8252 10 - * 11 - * > While redirect URIs using localhost (i.e., 12 - * > "http://localhost:{port}/{path}") function similarly to loopback IP 13 - * > redirects described in Section 7.3, the use of localhost is NOT 14 - * > RECOMMENDED. Specifying a redirect URI with the loopback IP literal rather 15 - * > than localhost avoids inadvertently listening on network interfaces other 16 - * > than the loopback interface. It is also less susceptible to client-side 17 - * > firewalls and misconfigured host name resolution on the user's device. 18 - */ 19 - export const loopbackRedirectUriSchema = loopbackUriSchema.chain((input) => { 20 - if (input.startsWith('http://localhost')) { 21 - return v.err( 22 - `use of "localhost" hostname is not allowed (RFC 8252), use a loopback IP such as "127.0.0.1" instead`, 23 - ); 24 - } 25 - return v.ok(input); 26 - }); 27 - 28 - export type LoopbackRedirectUri = v.Infer<typeof loopbackRedirectUriSchema>; 29 - 30 - export const oauthRedirectUriSchema = v.string().chain((input, options) => { 31 - if (input.startsWith('http://')) { 32 - return loopbackRedirectUriSchema.try(input, options); 33 - } 34 - 35 - if (input.startsWith('https://')) { 36 - return httpsUriSchema.try(input, options); 37 - } 38 - 39 - return privateUseUriSchema.try(input, options); 40 - }); 41 - 42 - export type OAuthRedirectUri = v.Infer<typeof oauthRedirectUriSchema>;
-9
packages/oauth/node-client/lib/schemas/oauth-response-mode.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - export const oauthResponseModeSchema = v.union( 4 - v.literal('query'), 5 - v.literal('fragment'), 6 - v.literal('form_post'), 7 - ); 8 - 9 - export type OAuthResponseMode = v.Infer<typeof oauthResponseModeSchema>;
-17
packages/oauth/node-client/lib/schemas/oauth-response-type.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - export const oauthResponseTypeSchema = v.union( 4 - // OAuth2 (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10#section-4.1.1) 5 - v.literal('code'), // Authorization Code Grant 6 - v.literal('token'), // Implicit Grant 7 - 8 - // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) 9 - v.literal('none'), 10 - v.literal('code id_token token'), 11 - v.literal('code id_token'), 12 - v.literal('code token'), 13 - v.literal('id_token token'), 14 - v.literal('id_token'), 15 - ); 16 - 17 - export type OAuthResponseType = v.Infer<typeof oauthResponseTypeSchema>;
-18
packages/oauth/node-client/lib/schemas/oauth-scope.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - // scope = scope-token *( SP scope-token ) 4 - // scope-token = 1*( %x21 / %x23-5B / %x5D-7E ) 5 - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-1.4.1 6 - export const OAUTH_SCOPE_REGEXP = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/; 7 - 8 - export const isOAuthScope = (input: string): boolean => OAUTH_SCOPE_REGEXP.test(input); 9 - 10 - /** 11 - * a (single) space separated list of non empty printable ASCII char string 12 - * (except backslash and double quote). 13 - * 14 - * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-1.4.1} 15 - */ 16 - export const oauthScopeSchema = v.string().assert(isOAuthScope, `invalid OAuth scope`); 17 - 18 - export type OAuthScope = v.Infer<typeof oauthScopeSchema>;
-22
packages/oauth/node-client/lib/schemas/oauth-token-response.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { oauthAuthorizationDetailsSchema } from './oauth-authorization-details.js'; 4 - import { oauthTokenTypeSchema } from './oauth-token-type.js'; 5 - 6 - /** 7 - * @see {@link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 | RFC 6749 (OAuth2), Section 5.1} 8 - */ 9 - export const oauthTokenResponseSchema = v.object({ 10 - // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 11 - access_token: v.string(), 12 - token_type: oauthTokenTypeSchema, 13 - scope: v.string().optional(), 14 - refresh_token: v.string().optional(), 15 - expires_in: v.number().optional(), 16 - // https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse 17 - id_token: v.string().optional(), 18 - // https://datatracker.ietf.org/doc/html/rfc9396#name-enriched-authorization-deta 19 - authorization_details: oauthAuthorizationDetailsSchema.optional(), 20 - }); 21 - 22 - export type OAuthTokenResponse = v.Infer<typeof oauthTokenResponseSchema>;
-15
packages/oauth/node-client/lib/schemas/oauth-token-type.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - /** token type (case-insensitive input, normalized output) */ 4 - export const oauthTokenTypeSchema = v.string().chain((input) => { 5 - const lower = input.toLowerCase(); 6 - if (lower === 'dpop') { 7 - return v.ok('DPoP'); 8 - } 9 - if (lower === 'bearer') { 10 - return v.ok('Bearer'); 11 - } 12 - return v.err(`must be "DPoP" or "Bearer"`); 13 - }); 14 - 15 - export type OAuthTokenType = v.Infer<typeof oauthTokenTypeSchema>;
-100
packages/oauth/node-client/lib/schemas/uri.ts
··· 1 - import * as v from '@badrap/valita'; 2 - 3 - import { isHostnameIP, isLocalHostname, isLoopbackHost } from './utils.js'; 4 - 5 - /** 6 - * valid, but potentially dangerous URL (`data:`, `file:`, `javascript:`, etc.). 7 - * 8 - * any value that matches this schema is safe to parse using `new URL()`. 9 - */ 10 - export const urlSchema = v.string().chain((input) => { 11 - if (input.includes(':') && URL.canParse(input)) { 12 - return v.ok(input); 13 - } 14 - return v.err(`must be a valid url`); 15 - }); 16 - 17 - /** loopback URL (http://localhost, http://127.0.0.1, http://[::1]) */ 18 - export const loopbackUriSchema = urlSchema.chain((input) => { 19 - if (!input.startsWith('http://')) { 20 - return v.err(`loopback url must use http: protocol`); 21 - } 22 - 23 - const url = new URL(input); 24 - if (!isLoopbackHost(url.hostname)) { 25 - return v.err(`loopback url must use localhost, 127.0.0.1, or [::1] as hostname`); 26 - } 27 - 28 - return v.ok(input); 29 - }); 30 - 31 - /** HTTPS URL with additional restrictions */ 32 - export const httpsUriSchema = urlSchema.chain((input) => { 33 - if (!input.startsWith('https://')) { 34 - return v.err(`url must use https: protocol`); 35 - } 36 - 37 - const url = new URL(input); 38 - 39 - if (isLoopbackHost(url.hostname)) { 40 - return v.err(`https url must not use a loopback host`); 41 - } 42 - 43 - if (!isHostnameIP(url.hostname)) { 44 - if (!url.hostname.includes('.')) { 45 - return v.err(`domain name must contain at least two segments`); 46 - } 47 - if (url.hostname.endsWith('.local')) { 48 - return v.err(`domain name must not end with .local`); 49 - } 50 - } 51 - 52 - return v.ok(input); 53 - }); 54 - 55 - /** web URL (either loopback http or https) */ 56 - export const webUriSchema = urlSchema.chain((input, options) => { 57 - if (input.startsWith('http://')) { 58 - return loopbackUriSchema.try(input, options); 59 - } 60 - 61 - if (input.startsWith('https://')) { 62 - return httpsUriSchema.try(input, options); 63 - } 64 - 65 - return v.err(`url must use http: or https: protocol`); 66 - }); 67 - 68 - /** web URL with a non-local hostname */ 69 - export const nonLocalWebUriSchema = webUriSchema.chain((input) => { 70 - const url = new URL(input); 71 - if (isLocalHostname(url.hostname)) { 72 - return v.err(`hostname is invalid`); 73 - } 74 - return v.ok(input); 75 - }); 76 - 77 - /** private-use URI scheme (e.g., com.example.app:/callback) */ 78 - export const privateUseUriSchema = urlSchema.chain((input) => { 79 - const dotIdx = input.indexOf('.'); 80 - const colonIdx = input.indexOf(':'); 81 - 82 - if (dotIdx === -1 || colonIdx === -1 || dotIdx > colonIdx) { 83 - return v.err(`private-use uri scheme must contain a dot in the protocol`); 84 - } 85 - 86 - const url = new URL(input); 87 - const scheme = url.protocol.slice(0, -1); 88 - const domain = scheme.split('.').reverse().join('.'); 89 - 90 - if (isLocalHostname(domain)) { 91 - return v.err(`private-use uri scheme must not be a local hostname`); 92 - } 93 - 94 - // RFC 8252: private-use URIs must use single slash after scheme 95 - if (url.href.startsWith(`${url.protocol}//`) || url.username || url.password || url.hostname || url.port) { 96 - return v.err(`private-use uri must be in the form scheme:/<path>`); 97 - } 98 - 99 - return v.ok(input); 100 - });
-113
packages/oauth/node-client/lib/schemas/utils.ts
··· 1 - /** 2 - * checks if a hostname is a loopback address 3 - */ 4 - export const isLoopbackHost = (hostname: string): boolean => { 5 - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; 6 - }; 7 - 8 - /** 9 - * checks if a hostname is an IP address (IPv4 or IPv6) 10 - */ 11 - export const isHostnameIP = (hostname: string): boolean => { 12 - // IPv4 13 - if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { 14 - return true; 15 - } 16 - // IPv6 17 - if (hostname.startsWith('[') && hostname.endsWith(']')) { 18 - return true; 19 - } 20 - return false; 21 - }; 22 - 23 - /** 24 - * checks if a hostname is a local/reserved hostname 25 - * 26 - * returns true for single-segment hostnames and reserved TLDs 27 - */ 28 - export const isLocalHostname = (hostname: string): boolean => { 29 - const parts = hostname.split('.'); 30 - if (parts.length < 2) { 31 - return true; 32 - } 33 - 34 - const tld = parts.at(-1)!.toLowerCase(); 35 - return tld === 'test' || tld === 'local' || tld === 'localhost' || tld === 'invalid' || tld === 'example'; 36 - }; 37 - 38 - /** 39 - * extracts the path from a URL without relying on URL constructor normalization 40 - * 41 - * this is needed because the URL constructor normalizes paths (e.g., removes `.` and `..` segments), 42 - * which can be used to bypass validation checks 43 - */ 44 - export const extractUrlPath = (url: string): string => { 45 - const endOfProtocol = url.startsWith('https://') ? 8 : url.startsWith('http://') ? 7 : -1; 46 - if (endOfProtocol === -1) { 47 - throw new TypeError(`url must use https: or http: protocol`); 48 - } 49 - 50 - const hashIdx = url.indexOf('#', endOfProtocol); 51 - const questionIdx = url.indexOf('?', endOfProtocol); 52 - 53 - const queryStrIdx = questionIdx !== -1 && (hashIdx === -1 || questionIdx < hashIdx) ? questionIdx : -1; 54 - 55 - const pathEnd = 56 - hashIdx === -1 57 - ? queryStrIdx === -1 58 - ? url.length 59 - : queryStrIdx 60 - : queryStrIdx === -1 61 - ? hashIdx 62 - : Math.min(hashIdx, queryStrIdx); 63 - 64 - const slashIdx = url.indexOf('/', endOfProtocol); 65 - const pathStart = slashIdx === -1 || slashIdx > pathEnd ? pathEnd : slashIdx; 66 - 67 - if (endOfProtocol === pathStart) { 68 - throw new TypeError(`url must contain a host`); 69 - } 70 - 71 - return url.substring(pathStart, pathEnd) || '/'; 72 - }; 73 - 74 - /** 75 - * checks if an item is the last occurrence in an array (for duplicate detection) 76 - */ 77 - export const isLastOccurrence = <T>(item: T, index: number, array: readonly T[]): boolean => { 78 - return array.lastIndexOf(item) === index; 79 - }; 80 - 81 - /** 82 - * checks if a space-separated string contains a specific value 83 - * 84 - * optimized version of `input.split(' ').includes(value)` 85 - */ 86 - export const isSpaceSeparatedValue = (value: string, input: string): boolean => { 87 - const inputLength = input.length; 88 - const valueLength = value.length; 89 - 90 - if (inputLength < valueLength) { 91 - return false; 92 - } 93 - 94 - let idx = input.indexOf(value); 95 - let idxEnd: number; 96 - 97 - while (idx !== -1) { 98 - idxEnd = idx + valueLength; 99 - 100 - if ( 101 - // at beginning or preceded by space 102 - (idx === 0 || input.charCodeAt(idx - 1) === 32) && 103 - // at end or followed by space 104 - (idxEnd === inputLength || input.charCodeAt(idxEnd) === 32) 105 - ) { 106 - return true; 107 - } 108 - 109 - idx = input.indexOf(value, idxEnd + 1); 110 - } 111 - 112 - return false; 113 - };
+1 -2
packages/oauth/node-client/lib/types/token-set.ts
··· 1 1 import type { Did } from '@atcute/lexicons'; 2 - 3 - import type { AtprotoOAuthScope } from '../schemas/atproto-oauth-scope.js'; 2 + import type { AtprotoOAuthScope } from '@atcute/oauth-types'; 4 3 5 4 /** 6 5 * token set returned from token operations (exchange, refresh).
+2
packages/oauth/node-client/package.json
··· 32 32 "@atcute/identity-resolver": "workspace:^", 33 33 "@atcute/lexicons": "workspace:^", 34 34 "@atcute/multibase": "workspace:^", 35 + "@atcute/oauth-types": "workspace:^", 36 + "@atcute/oauth-keyset": "workspace:^", 35 37 "@atcute/uint8array": "workspace:^", 36 38 "@atcute/util-fetch": "workspace:^", 37 39 "@badrap/valita": "^0.4.6",