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.

feat(oauth-types): initial commit

Mary cd3b1b2f 853514cf

+1604
+102
packages/oauth/types/README.md
··· 1 + # @atcute/oauth-types 2 + 3 + OAuth types and schemas for AT Protocol. 4 + 5 + ## installation 6 + 7 + ```sh 8 + npm install @atcute/oauth-types 9 + ``` 10 + 11 + ## usage 12 + 13 + ### building client metadata 14 + 15 + ```ts 16 + import { buildClientMetadata } from '@atcute/oauth-types'; 17 + import { Keyset } from '@atcute/oauth-keyset'; 18 + 19 + const metadata = buildClientMetadata( 20 + { 21 + client_id: 'https://example.com/client-metadata.json', 22 + redirect_uris: ['https://example.com/callback'], 23 + scope: 'atproto transition:generic', 24 + client_name: 'my app', 25 + }, 26 + keyset, 27 + ); 28 + ``` 29 + 30 + ### using schemas 31 + 32 + ```ts 33 + import { 34 + confidentialClientMetadataSchema, 35 + oauthClientMetadataSchema, 36 + oauthTokenResponseSchema, 37 + oauthAuthorizationServerMetadataSchema, 38 + } from '@atcute/oauth-types'; 39 + 40 + // validate input 41 + const result = confidentialClientMetadataSchema.try(input); 42 + if (result.ok) { 43 + console.log(result.value); 44 + } 45 + ``` 46 + 47 + ## exports 48 + 49 + - `buildClientMetadata` - builds atproto client metadata from configuration 50 + - `FALLBACK_ALG` - default algorithm per atproto spec ('ES256') 51 + - `CLIENT_ASSERTION_TYPE_JWT_BEARER` - JWT bearer assertion type 52 + 53 + ### schemas 54 + 55 + **client metadata:** 56 + 57 + - `confidentialClientMetadataSchema` - user-facing metadata input 58 + - `oauthClientMetadataSchema` - full OAuth client metadata 59 + 60 + **tokens:** 61 + 62 + - `oauthTokenResponseSchema` - OAuth token response 63 + - `oauthTokenTypeSchema` - token type (Bearer, DPoP) 64 + - `atprotoOAuthTokenResponseSchema` - AT Protocol token response 65 + 66 + **authorization server:** 67 + 68 + - `oauthAuthorizationServerMetadataSchema` - OAuth AS metadata 69 + - `atprotoAuthorizationServerMetadataSchema` - AT Protocol AS metadata 70 + - `oauthIssuerIdentifierSchema` - issuer identifier 71 + 72 + **protected resource:** 73 + 74 + - `oauthProtectedResourceMetadataSchema` - OAuth PRM 75 + - `atprotoProtectedResourceMetadataSchema` - AT Protocol PRM 76 + 77 + **PAR:** 78 + 79 + - `oauthParResponseSchema` - PAR response 80 + - `oauthCodeChallengeMethodSchema` - code challenge method (S256, plain) 81 + - `oauthResponseModeSchema` - response mode 82 + 83 + **authorization:** 84 + 85 + - `oauthAuthorizationDetailsSchema` - authorization details 86 + 87 + **keys:** 88 + 89 + - `jwkSchema`, `jwkPubSchema` - JWK schemas 90 + - `jwksSchema`, `jwksPubSchema` - JWKS schemas 91 + 92 + **URIs:** 93 + 94 + - `urlSchema`, `httpsUriSchema`, `webUriSchema`, etc. 95 + 96 + **other:** 97 + 98 + - `oauthScopeSchema`, `oauthGrantTypeSchema`, etc. 99 + 100 + ## license 101 + 102 + 0BSD
+72
packages/oauth/types/lib/build-client-metadata.ts
··· 1 + import type { Keyset } from '@atcute/oauth-keyset'; 2 + 3 + import { FALLBACK_ALG } from './constants.js'; 4 + import { 5 + confidentialClientMetadataSchema, 6 + type ConfidentialClientMetadata, 7 + } from './schemas/atcute-confidential-client-metadata.js'; 8 + import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 9 + 10 + /** 11 + * builds an atproto client metadata 12 + * 13 + * 14 + * @param input client metadata 15 + * @param keyset available keys 16 + * @returns built client metadata 17 + */ 18 + export const buildClientMetadata = ( 19 + input: ConfidentialClientMetadata, 20 + keyset: Keyset, 21 + ): OAuthClientMetadata => { 22 + // validate user-facing schema is correct 23 + const conf = confidentialClientMetadataSchema.parse(input, { mode: 'passthrough' }); 24 + 25 + // build full OAuth client metadata (atproto defaults and requirements) 26 + const metadata: OAuthClientMetadata = { 27 + client_id: conf.client_id, 28 + client_name: conf.client_name, 29 + client_uri: conf.client_uri, 30 + policy_uri: conf.policy_uri, 31 + tos_uri: conf.tos_uri, 32 + logo_uri: conf.logo_uri, 33 + redirect_uris: conf.redirect_uris, 34 + scope: Array.isArray(conf.scope) ? conf.scope.join(' ') : conf.scope, 35 + 36 + application_type: 'web', 37 + subject_type: 'public', 38 + response_types: ['code'], 39 + grant_types: ['authorization_code', 'refresh_token'], 40 + 41 + token_endpoint_auth_method: 'private_key_jwt', 42 + token_endpoint_auth_signing_alg: FALLBACK_ALG, 43 + dpop_bound_access_tokens: true, 44 + 45 + jwks_uri: conf.jwks_uri, 46 + jwks: conf.jwks_uri ? undefined : (keyset.publicJwks as OAuthClientMetadata['jwks']), 47 + }; 48 + 49 + // ensure at least one key supports the fallback algorithm 50 + const signingKeys = Array.from(keyset); 51 + if (!signingKeys.some((key) => key.alg === FALLBACK_ALG)) { 52 + throw new TypeError(`"private_key_jwt" requires at least one "${FALLBACK_ALG}" signing key`); 53 + } 54 + 55 + // if jwks provided inline, ensure ALL signing keys are present 56 + if (metadata.jwks) { 57 + const jwksKids = new Set( 58 + metadata.jwks.keys 59 + .filter((k) => !k.revoked) 60 + .map((k) => k.kid) 61 + .filter(Boolean), 62 + ); 63 + 64 + for (const key of signingKeys) { 65 + if (!jwksKids.has(key.kid)) { 66 + throw new TypeError(`signing key "${key.kid}" not found in jwks`); 67 + } 68 + } 69 + } 70 + 71 + return metadata; 72 + };
+5
packages/oauth/types/lib/constants.ts
··· 1 + /** default algorithm per atproto spec */ 2 + export const FALLBACK_ALG = 'ES256'; 3 + 4 + /** JWT bearer assertion type for `private_key_jwt` authentication */ 5 + export const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
+113
packages/oauth/types/lib/index.ts
··· 1 + export { buildClientMetadata } from './build-client-metadata.js'; 2 + export { CLIENT_ASSERTION_TYPE_JWT_BEARER, FALLBACK_ALG } from './constants.js'; 3 + 4 + // schemas 5 + export { 6 + confidentialClientMetadataSchema, 7 + type ConfidentialClientMetadata, 8 + } from './schemas/atcute-confidential-client-metadata.js'; 9 + export { 10 + atprotoOAuthScopeSchema, 11 + ATPROTO_SCOPE_VALUE, 12 + DEFAULT_ATPROTO_OAUTH_SCOPE, 13 + type AtprotoOAuthScope, 14 + } from './schemas/atproto-oauth-scope.js'; 15 + export { 16 + jwkPubSchema, 17 + jwkSchema, 18 + keyUsageSchema, 19 + publicKeyUsageSchema, 20 + type Jwk, 21 + type JwkPub, 22 + type KeyUsage, 23 + } from './schemas/jwk.js'; 24 + export { jwksPubSchema, jwksSchema, type Jwks, type JwksPub } from './schemas/jwks.js'; 25 + export { oauthClientIdDiscoverableSchema } from './schemas/oauth-client-id-discoverable.js'; 26 + export { oauthClientIdSchema, type OAuthClientId } from './schemas/oauth-client-id.js'; 27 + export { oauthClientMetadataSchema, type OAuthClientMetadata } from './schemas/oauth-client-metadata.js'; 28 + export { 29 + oauthEndpointAuthMethodSchema, 30 + type OAuthEndpointAuthMethod, 31 + } from './schemas/oauth-endpoint-auth-method.js'; 32 + export { oauthGrantTypeSchema, type OAuthGrantType } from './schemas/oauth-grant-type.js'; 33 + export { 34 + loopbackRedirectUriSchema, 35 + oauthRedirectUriSchema, 36 + type LoopbackRedirectUri, 37 + type OAuthRedirectUri, 38 + } from './schemas/oauth-redirect-uri.js'; 39 + export { oauthResponseTypeSchema, type OAuthResponseType } from './schemas/oauth-response-type.js'; 40 + export { 41 + isOAuthScope, 42 + OAUTH_SCOPE_REGEXP, 43 + oauthScopeSchema, 44 + type OAuthScope, 45 + } from './schemas/oauth-scope.js'; 46 + export { 47 + httpsUriSchema, 48 + loopbackUriSchema, 49 + nonLocalWebUriSchema, 50 + privateUseUriSchema, 51 + urlSchema, 52 + webUriSchema, 53 + } from './schemas/uri.js'; 54 + export { 55 + extractUrlPath, 56 + isHostnameIP, 57 + isLastOccurrence, 58 + isLocalHostname, 59 + isLoopbackHost, 60 + isSpaceSeparatedValue, 61 + } from './schemas/utils.js'; 62 + 63 + // token schemas 64 + export { oauthTokenTypeSchema, type OAuthTokenType } from './schemas/oauth-token-type.js'; 65 + export { oauthTokenResponseSchema, type OAuthTokenResponse } from './schemas/oauth-token-response.js'; 66 + export { 67 + atprotoOAuthTokenResponseSchema, 68 + type AtprotoOAuthTokenResponse, 69 + } from './schemas/atproto-oauth-token-response.js'; 70 + 71 + // PAR schemas 72 + export { oauthParResponseSchema, type OAuthParResponse } from './schemas/oauth-par-response.js'; 73 + export { 74 + oauthCodeChallengeMethodSchema, 75 + type OAuthCodeChallengeMethod, 76 + } from './schemas/oauth-code-challenge-method.js'; 77 + export { oauthResponseModeSchema, type OAuthResponseMode } from './schemas/oauth-response-mode.js'; 78 + 79 + // authorization details 80 + export { 81 + oauthAuthorizationDetailSchema, 82 + oauthAuthorizationDetailsSchema, 83 + type OAuthAuthorizationDetail, 84 + type OAuthAuthorizationDetails, 85 + } from './schemas/oauth-authorization-details.js'; 86 + 87 + // server metadata 88 + export { 89 + oauthIssuerIdentifierSchema, 90 + type OAuthIssuerIdentifier, 91 + } from './schemas/oauth-issuer-identifier.js'; 92 + export { 93 + oauthAuthorizationServerMetadataSchema, 94 + oauthAuthorizationServerMetadataValidator, 95 + type OAuthAuthorizationServerMetadata, 96 + } from './schemas/oauth-authorization-server-metadata.js'; 97 + export { 98 + atprotoAuthorizationServerMetadataValidator, 99 + type AtprotoAuthorizationServerMetadata, 100 + } from './schemas/atproto-authorization-server-metadata.js'; 101 + 102 + // protected resource metadata 103 + export { 104 + oauthBearerMethodSchema, 105 + oauthProtectedResourceMetadataSchema, 106 + oauthProtectedResourceMetadataValidator, 107 + type OAuthBearerMethod, 108 + type OAuthProtectedResourceMetadata, 109 + } from './schemas/oauth-protected-resource-metadata.js'; 110 + export { 111 + atprotoProtectedResourceMetadataValidator, 112 + type AtprotoProtectedResourceMetadata, 113 + } from './schemas/atproto-protected-resource-metadata.js';
+139
packages/oauth/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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/types/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 + };
+37
packages/oauth/types/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/oauth-types", 4 + "version": "0.1.0", 5 + "description": "OAuth types and schemas for AT Protocol", 6 + "license": "0BSD", 7 + "repository": { 8 + "url": "https://github.com/mary-ext/atcute", 9 + "directory": "packages/oauth/types" 10 + }, 11 + "publishConfig": { 12 + "access": "public" 13 + }, 14 + "files": [ 15 + "dist/", 16 + "lib/", 17 + "!lib/**/*.bench.ts", 18 + "!lib/**/*.test.ts" 19 + ], 20 + "exports": { 21 + ".": "./dist/index.js" 22 + }, 23 + "sideEffects": false, 24 + "scripts": { 25 + "build": "tsgo --project tsconfig.build.json", 26 + "test": "vitest", 27 + "prepublish": "rm -rf dist; pnpm run build" 28 + }, 29 + "dependencies": { 30 + "@atcute/identity": "workspace:^", 31 + "@atcute/oauth-keyset": "workspace:^", 32 + "@badrap/valita": "^0.4.6" 33 + }, 34 + "devDependencies": { 35 + "vitest": "^4.0.16" 36 + } 37 + }
+4
packages/oauth/types/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["lib/**/*.test.ts", "lib/**/*.bench.ts"] 4 + }
+24
packages/oauth/types/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": [], 4 + "outDir": "dist/", 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true 22 + }, 23 + "include": ["lib"] 24 + }