···11-import { FALLBACK_ALG } from './constants.js';
22-import type { Keyset } from './keyset/keyset.js';
33-import {
44- confidentialClientMetadataSchema,
55- type ConfidentialClientMetadata,
66-} from './schemas/atcute-confidential-client-metadata.js';
77-import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
88-99-/**
1010- * builds an atproto client metadata
1111- *
1212- *
1313- * @param input client metadata
1414- * @param keyset available keys
1515- * @returns built client metadata
1616- */
1717-export const buildClientMetadata = (
1818- input: ConfidentialClientMetadata,
1919- keyset: Keyset,
2020-): OAuthClientMetadata => {
2121- // validate user-facing schema is correct
2222- const conf = confidentialClientMetadataSchema.parse(input, { mode: 'passthrough' });
2323-2424- // build full OAuth client metadata (atproto defaults and requirements)
2525- const metadata: OAuthClientMetadata = {
2626- client_id: conf.client_id,
2727- client_name: conf.client_name,
2828- client_uri: conf.client_uri,
2929- policy_uri: conf.policy_uri,
3030- tos_uri: conf.tos_uri,
3131- logo_uri: conf.logo_uri,
3232- redirect_uris: conf.redirect_uris,
3333- scope: Array.isArray(conf.scope) ? conf.scope.join(' ') : conf.scope,
3434-3535- application_type: 'web',
3636- subject_type: 'public',
3737- response_types: ['code'],
3838- grant_types: ['authorization_code', 'refresh_token'],
3939-4040- token_endpoint_auth_method: 'private_key_jwt',
4141- token_endpoint_auth_signing_alg: FALLBACK_ALG,
4242- dpop_bound_access_tokens: true,
4343-4444- jwks_uri: conf.jwks_uri,
4545- jwks: conf.jwks_uri ? undefined : (keyset.publicJwks as OAuthClientMetadata['jwks']),
4646- };
4747-4848- // ensure at least one key supports the fallback algorithm
4949- const signingKeys = Array.from(keyset);
5050- if (!signingKeys.some((key) => key.alg === FALLBACK_ALG)) {
5151- throw new TypeError(`"private_key_jwt" requires at least one "${FALLBACK_ALG}" signing key`);
5252- }
5353-5454- // if jwks provided inline, ensure ALL signing keys are present
5555- if (metadata.jwks) {
5656- const jwksKids = new Set(
5757- metadata.jwks.keys
5858- .filter((k) => !k.revoked)
5959- .map((k) => k.kid)
6060- .filter(Boolean),
6161- );
6262-6363- for (const key of signingKeys) {
6464- if (!jwksKids.has(key.kid)) {
6565- throw new TypeError(`signing key "${key.kid}" not found in jwks`);
6666- }
6767- }
6868- }
6969-7070- return metadata;
7171-};
-6
packages/oauth/node-client/lib/constants.ts
···12121313/** max size for PAR responses */
1414export const PAR_RESPONSE_MAX_SIZE = 1024; // 1KB
1515-1616-/** default algorithm per atproto spec */
1717-export const FALLBACK_ALG = 'ES256';
1818-1919-/** JWT bearer assertion type for `private_key_jwt` authentication */
2020-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
···11-export { buildClientMetadata } from './build-client-metadata.js';
11+export {
22+ exportJwkKey,
33+ exportPkcs8Key,
44+ generatePrivateKey,
55+ importJwkKey,
66+ importPkcs8Key,
77+ Keyset,
88+ type ImportKeyOptions,
99+ type KeySearchOptions,
1010+ type PrivateKey,
1111+ type SigningAlgorithm,
1212+} from '@atcute/oauth-keyset';
1313+1414+export {
1515+ buildClientMetadata,
1616+ CLIENT_ASSERTION_TYPE_JWT_BEARER,
1717+ FALLBACK_ALG,
1818+ type AtprotoAuthorizationServerMetadata,
1919+ type AtprotoProtectedResourceMetadata,
2020+ type ConfidentialClientMetadata,
2121+ type OAuthAuthorizationServerMetadata,
2222+ type OAuthClientMetadata,
2323+ type OAuthProtectedResourceMetadata,
2424+ type OAuthResponseMode,
2525+} from '@atcute/oauth-types';
2626+227export * as scope from './scope.js';
328export {
429 OAuthClient,
···1641export type { SessionEvent, SessionEventListener } from './session-getter.js';
17421843export {
1919- exportJwkKey,
2020- exportPkcs8Key,
2121- generatePrivateKey,
2222- importJwkKey,
2323- importPkcs8Key,
2424-} from './keyset/import-key.js';
2525-export { Keyset } from './keyset/keyset.js';
2626-export type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './keyset/types.js';
2727-2828-export {
2944 AuthMethodUnsatisfiableError,
3045 OAuthCallbackError,
3146 OAuthResolverError,
···4560export type { SessionStore, StoredSession } from './types/sessions.js';
4661export type { StateStore, StoredState } from './types/states.js';
4762export type { TokenSet } from './types/token-set.js';
4848-4949-export type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js';
5050-export type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js';
5151-export type { AtprotoProtectedResourceMetadata } from './schemas/atproto-protected-resource-metadata.js';
5252-export type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
5353-export type { OAuthResponseMode } from './schemas/oauth-response-mode.js';
···11import { SignJWT } from 'jose';
22import { nanoid } from 'nanoid';
3344-import { CLIENT_ASSERTION_TYPE_JWT_BEARER, FALLBACK_ALG } from './constants.js';
55-import type { Keyset } from './keyset/keyset.js';
66-import type { PrivateKey } from './keyset/types.js';
77-import type { OAuthAuthorizationServerMetadata } from './schemas/oauth-authorization-server-metadata.js';
44+import {
55+ CLIENT_ASSERTION_TYPE_JWT_BEARER,
66+ FALLBACK_ALG,
77+ type OAuthAuthorizationServerMetadata,
88+} from '@atcute/oauth-types';
99+import type { Keyset, PrivateKey } from '@atcute/oauth-keyset';
810911export { CLIENT_ASSERTION_TYPE_JWT_BEARER };
1012
+8-7
packages/oauth/node-client/lib/oauth-client.ts
···3344import type { ActorResolver } from '@atcute/identity-resolver';
55import type { ActorIdentifier, Did } from '@atcute/lexicons';
66+import {
77+ buildClientMetadata,
88+ FALLBACK_ALG,
99+ type ConfidentialClientMetadata,
1010+ type OAuthClientMetadata,
1111+ type OAuthResponseMode,
1212+} from '@atcute/oauth-types';
1313+import { Keyset, type PrivateKey } from '@atcute/oauth-keyset';
61477-import { buildClientMetadata } from './build-client-metadata.js';
88-import { FALLBACK_ALG } from './constants.js';
915import type { DpopNonceCache } from './dpop/fetch-dpop.js';
1016import { generateDpopKey } from './dpop/generate-key.js';
1117import { OAuthCallbackError, TokenRevokedError } from './errors.js';
1212-import { Keyset } from './keyset/keyset.js';
1313-import type { PrivateKey } from './keyset/types.js';
1418import { OAuthServerAgent } from './oauth-server-agent.js';
1519import { OAuthServerFactory } from './oauth-server-factory.js';
1620import { OAuthSession } from './oauth-session.js';
···2428 ProtectedResourceMetadataResolver,
2529 type ProtectedResourceMetadataCache,
2630} from './resolvers/protected-resource-metadata.js';
2727-import type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js';
2828-import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
2929-import type { OAuthResponseMode } from './schemas/oauth-response-mode.js';
3031import { SessionGetter, type SessionEventListener } from './session-getter.js';
3132import type { SessionStore } from './types/sessions.js';
3233import type { StateStore, StoredState } from './types/states.js';
···11import { describe, expect, it, vi } from 'vitest';
2233import type { Did } from '@atcute/lexicons';
44+import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types';
55+import { generatePrivateKey, Keyset } from '@atcute/oauth-keyset';
4657import { generateDpopKey } from './dpop/generate-key.js';
68import { OAuthResponseError, TokenRefreshError } from './errors.js';
77-import { generatePrivateKey } from './keyset/import-key.js';
88-import { Keyset } from './keyset/keyset.js';
99import { OAuthServerAgent, type OAuthServerAgentOptions } from './oauth-server-agent.js';
1010import type { OAuthResolver } from './resolvers/index.js';
1111-import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js';
1211import { MemoryStore } from './utils/memory-store.js';
13121413const createMockMetadata = (): AtprotoAuthorizationServerMetadata => ({
···11import type { JWK } from 'jose';
2233import type { Did } from '@atcute/lexicons';
44+import {
55+ atprotoOAuthTokenResponseSchema,
66+ oauthParResponseSchema,
77+ type AtprotoAuthorizationServerMetadata,
88+ type AtprotoOAuthTokenResponse,
99+ type OAuthClientMetadata,
1010+ type OAuthParResponse,
1111+} from '@atcute/oauth-types';
1212+import type { Keyset } from '@atcute/oauth-keyset';
413import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch';
514615import { JSON_MIME, PAR_RESPONSE_MAX_SIZE, TOKEN_RESPONSE_MAX_SIZE } from './constants.js';
716import { createDpopFetch } from './dpop/fetch-dpop.js';
817import { OAuthResponseError, TokenRefreshError } from './errors.js';
99-import { Keyset } from './keyset/keyset.js';
1018import {
1119 createClientAssertionFactory,
1220 type ClientAuthMethod,
1321 type ClientCredentialsFactory,
1422} from './oauth-client-auth.js';
1523import { OAuthResolver } from './resolvers/index.js';
1616-import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js';
1717-import {
1818- atprotoOAuthTokenResponseSchema,
1919- type AtprotoOAuthTokenResponse,
2020-} from './schemas/atproto-oauth-token-response.js';
2121-import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
2222-import { oauthParResponseSchema, type OAuthParResponse } from './schemas/oauth-par-response.js';
2324import type { TokenSet } from './types/token-set.js';
2425import type { Store } from './utils/store.js';
2526
···11import type { JWK } from 'jose';
2233-import { Keyset } from './keyset/keyset.js';
33+import type { AtprotoAuthorizationServerMetadata, OAuthClientMetadata } from '@atcute/oauth-types';
44+import type { Keyset } from '@atcute/oauth-keyset';
55+46import { type ClientAuthMethod, negotiateClientAuth } from './oauth-client-auth.js';
57import { OAuthServerAgent } from './oauth-server-agent.js';
68import { OAuthResolver } from './resolvers/index.js';
77-import type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js';
88-import type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
99import type { Store } from './utils/store.js';
10101111export interface OAuthServerFactoryOptions {
+1-1
packages/oauth/node-client/lib/oauth-session.ts
···11import type { FetchHandlerObject } from '@atcute/client';
22import type { Did } from '@atcute/lexicons';
33+import type { AtprotoOAuthScope } from '@atcute/oauth-types';
3445import { createDpopFetch } from './dpop/fetch-dpop.js';
56import { TokenInvalidError, TokenRevokedError } from './errors.js';
67import type { OAuthServerAgent } from './oauth-server-agent.js';
77-import type { AtprotoOAuthScope } from './schemas/atproto-oauth-scope.js';
88import type { SessionGetter } from './session-getter.js';
99import type { TokenSet } from './types/token-set.js';
1010
···11import { describe, expect, it, vi } from 'vitest';
2233-import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js';
33+import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types';
44+45import { MemoryStore } from '../utils/memory-store.js';
5667import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js';
···11+import {
22+ atprotoAuthorizationServerMetadataValidator,
33+ oauthIssuerIdentifierSchema,
44+ type AtprotoAuthorizationServerMetadata,
55+} from '@atcute/oauth-types';
16import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch';
2738import { AS_METADATA_MAX_SIZE, JSON_MIME } from '../constants.js';
49import { OAuthResolverError } from '../errors.js';
55-import {
66- atprotoAuthorizationServerMetadataValidator,
77- type AtprotoAuthorizationServerMetadata,
88-} from '../schemas/atproto-authorization-server-metadata.js';
99-import { oauthIssuerIdentifierSchema } from '../schemas/oauth-issuer-identifier.js';
1010import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js';
1111import type { Store } from '../utils/store.js';
1212
+1-1
packages/oauth/node-client/lib/resolvers/index.ts
···11import type { ActorResolver, ResolvedActor } from '@atcute/identity-resolver';
22import type { ActorIdentifier } from '@atcute/lexicons';
33+import type { AtprotoAuthorizationServerMetadata } from '@atcute/oauth-types';
3445import { OAuthResolverError } from '../errors.js';
55-import type { AtprotoAuthorizationServerMetadata } from '../schemas/atproto-authorization-server-metadata.js';
6677import { AuthorizationServerMetadataResolver } from './authorization-server-metadata.js';
88import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js';
···11import { describe, expect, it, vi } from 'vitest';
2233-import type { AtprotoProtectedResourceMetadata } from '../schemas/atproto-protected-resource-metadata.js';
33+import type { AtprotoProtectedResourceMetadata } from '@atcute/oauth-types';
44+45import { MemoryStore } from '../utils/memory-store.js';
5667import { ProtectedResourceMetadataResolver } from './protected-resource-metadata.js';
···11+import {
22+ atprotoProtectedResourceMetadataValidator,
33+ type AtprotoProtectedResourceMetadata,
44+} from '@atcute/oauth-types';
15import { parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch';
2637import { JSON_MIME, PR_METADATA_MAX_SIZE } from '../constants.js';
48import { OAuthResolverError } from '../errors.js';
55-import {
66- atprotoProtectedResourceMetadataValidator,
77- type AtprotoProtectedResourceMetadata,
88-} from '../schemas/atproto-protected-resource-metadata.js';
99import { CachedGetter, type GetCachedOptions } from '../utils/cached-getter.js';
1010import type { Store } from '../utils/store.js';
1111
···11-import * as v from '@badrap/valita';
22-33-import { oauthProtectedResourceMetadataValidator } from './oauth-protected-resource-metadata.js';
44-55-/**
66- * AT Protocol protected resource metadata with required fields.
77- *
88- * @see {@link https://atproto.com/specs/oauth}
99- */
1010-export const atprotoProtectedResourceMetadataValidator = oauthProtectedResourceMetadataValidator.chain(
1111- (data) => {
1212- // atproto requires exactly one authorization server
1313- if (data.authorization_servers?.length !== 1) {
1414- return v.err({
1515- message: `atproto requires exactly one authorization server`,
1616- path: ['authorization_servers'],
1717- });
1818- }
1919-2020- return v.ok(data as typeof data & { authorization_servers: [string] });
2121- },
2222-);
2323-2424-export type AtprotoProtectedResourceMetadata = v.Infer<typeof atprotoProtectedResourceMetadataValidator>;
-189
packages/oauth/node-client/lib/schemas/jwk.ts
···11-import * as v from '@badrap/valita';
22-33-import { isLastOccurrence } from './utils.js';
44-55-// key usage constants
66-const PUBLIC_KEY_USAGE = ['verify', 'encrypt', 'wrapKey'] as const;
77-const PRIVATE_KEY_USAGE = ['sign', 'decrypt', 'unwrapKey', 'deriveKey', 'deriveBits'] as const;
88-const KEY_USAGE = [...PRIVATE_KEY_USAGE, ...PUBLIC_KEY_USAGE] as const;
99-1010-type InternalKeyUsage = (typeof KEY_USAGE)[number];
1111-1212-const isPublicKeyUsage = (usage: unknown): usage is (typeof PUBLIC_KEY_USAGE)[number] => {
1313- return (PUBLIC_KEY_USAGE as readonly unknown[]).includes(usage);
1414-};
1515-1616-const isPrivateKeyUsage = (usage: unknown): usage is (typeof PRIVATE_KEY_USAGE)[number] => {
1717- return (PRIVATE_KEY_USAGE as readonly unknown[]).includes(usage);
1818-};
1919-2020-const isSigKeyUsage = (v: InternalKeyUsage): boolean => v === 'verify';
2121-const isEncKeyUsage = (v: InternalKeyUsage): boolean => v === 'encrypt' || v === 'wrapKey';
2222-2323-export const keyUsageSchema = v.union(
2424- v.literal('verify'),
2525- v.literal('encrypt'),
2626- v.literal('wrapKey'),
2727- v.literal('sign'),
2828- v.literal('decrypt'),
2929- v.literal('unwrapKey'),
3030- v.literal('deriveKey'),
3131- v.literal('deriveBits'),
3232-);
3333-3434-export const publicKeyUsageSchema = v.union(v.literal('verify'), v.literal('encrypt'), v.literal('wrapKey'));
3535-3636-const jwkBaseSchema = v.object({
3737- kty: v.string(),
3838- alg: v.string().optional(),
3939- kid: v.string().optional(),
4040- use: v.union(v.literal('sig'), v.literal('enc')).optional(),
4141- key_ops: v.array(keyUsageSchema).optional(),
4242-4343- // X.509
4444- x5c: v.array(v.string()).optional(),
4545- x5t: v.string().optional(),
4646- 'x5t#S256': v.string().optional(),
4747- x5u: v.string().optional(),
4848-4949- // WebCrypto
5050- ext: v.boolean().optional(),
5151-5252- // Federation Historical Keys Response
5353- iat: v.number().optional(),
5454- exp: v.number().optional(),
5555- nbf: v.number().optional(),
5656- revoked: v
5757- .object({
5858- revoked_at: v.number(),
5959- reason: v.string().optional(),
6060- })
6161- .optional(),
6262-});
6363-6464-const jwkRsaKeySchema = jwkBaseSchema.extend({
6565- kty: v.literal('RSA'),
6666- alg: v
6767- .union(
6868- v.literal('RS256'),
6969- v.literal('RS384'),
7070- v.literal('RS512'),
7171- v.literal('PS256'),
7272- v.literal('PS384'),
7373- v.literal('PS512'),
7474- )
7575- .optional(),
7676- n: v.string(),
7777- e: v.string(),
7878- d: v.string().optional(),
7979- p: v.string().optional(),
8080- q: v.string().optional(),
8181- dp: v.string().optional(),
8282- dq: v.string().optional(),
8383- qi: v.string().optional(),
8484- oth: v
8585- .array(
8686- v.object({
8787- r: v.string().optional(),
8888- d: v.string().optional(),
8989- t: v.string().optional(),
9090- }),
9191- )
9292- .optional(),
9393-});
9494-9595-const jwkEcKeySchema = jwkBaseSchema.extend({
9696- kty: v.literal('EC'),
9797- alg: v.union(v.literal('ES256'), v.literal('ES384'), v.literal('ES512')).optional(),
9898- crv: v.union(v.literal('P-256'), v.literal('P-384'), v.literal('P-521')),
9999- x: v.string(),
100100- y: v.string(),
101101- d: v.string().optional(),
102102-});
103103-104104-const jwkEcSecp256k1KeySchema = jwkBaseSchema.extend({
105105- kty: v.literal('EC'),
106106- alg: v.literal('ES256K').optional(),
107107- crv: v.literal('secp256k1'),
108108- x: v.string(),
109109- y: v.string(),
110110- d: v.string().optional(),
111111-});
112112-113113-const jwkOkpKeySchema = jwkBaseSchema.extend({
114114- kty: v.literal('OKP'),
115115- alg: v.literal('EdDSA').optional(),
116116- crv: v.union(v.literal('Ed25519'), v.literal('Ed448')),
117117- x: v.string(),
118118- d: v.string().optional(),
119119-});
120120-121121-const jwkSymKeySchema = jwkBaseSchema.extend({
122122- kty: v.literal('oct'),
123123- alg: v.union(v.literal('HS256'), v.literal('HS384'), v.literal('HS512')).optional(),
124124- k: v.string(),
125125-});
126126-127127-const hasPrivateSecret = <J extends object>(jwk: J): boolean => {
128128- return ('d' in jwk && jwk.d != null) || ('k' in jwk && jwk.k != null);
129129-};
130130-131131-const isPublicJwk = <J extends object>(jwk: J): boolean => {
132132- return !hasPrivateSecret(jwk);
133133-};
134134-135135-/** JWK schema for known key types */
136136-export const jwkSchema = v
137137- .union(jwkRsaKeySchema, jwkEcKeySchema, jwkEcSecp256k1KeySchema, jwkOkpKeySchema, jwkSymKeySchema)
138138- .chain((k) => {
139139- // "use" can only be used with public keys
140140- if (k.use != null && !isPublicJwk(k)) {
141141- return v.err({ message: `"use" can only be used with public keys`, path: ['use'] });
142142- }
143143-144144- // private key usage not allowed for public keys
145145- if (k.key_ops?.some(isPrivateKeyUsage) && isPublicJwk(k)) {
146146- return v.err({ message: `private key usage not allowed for public keys`, path: ['key_ops'] });
147147- }
148148-149149- // key_ops must not contain duplicates
150150- if (k.key_ops && !k.key_ops.every(isLastOccurrence)) {
151151- return v.err({ message: `key_ops must not contain duplicates`, path: ['key_ops'] });
152152- }
153153-154154- // "use" and "key_ops" must be consistent
155155- if (k.use != null && k.key_ops != null) {
156156- const consistent =
157157- (k.use === 'sig' && k.key_ops.every(isSigKeyUsage)) ||
158158- (k.use === 'enc' && k.key_ops.every(isEncKeyUsage));
159159- if (!consistent) {
160160- return v.err({ message: `"key_ops" must be consistent with "use"`, path: ['key_ops'] });
161161- }
162162- }
163163-164164- return v.ok(k);
165165- });
166166-167167-/** public JWK schema (kid required, no private keys) */
168168-export const jwkPubSchema = jwkSchema.chain((k) => {
169169- if (k.kid == null) {
170170- return v.err({ message: `"kid" is required`, path: ['kid'] });
171171- }
172172-173173- if (!isPublicJwk(k)) {
174174- return v.err({ message: `private key not allowed` });
175175- }
176176-177177- if (k.key_ops && !k.key_ops.every(isPublicKeyUsage)) {
178178- return v.err({
179179- message: `"key_ops" must not contain private key usage for public keys`,
180180- path: ['key_ops'],
181181- });
182182- }
183183-184184- return v.ok(k);
185185-});
186186-187187-export type KeyUsage = v.Infer<typeof keyUsageSchema>;
188188-export type Jwk = v.Infer<typeof jwkSchema>;
189189-export type JwkPub = v.Infer<typeof jwkPubSchema>;
-45
packages/oauth/node-client/lib/schemas/jwks.ts
···11-import * as v from '@badrap/valita';
22-33-import { jwkPubSchema, jwkSchema, type Jwk, type JwkPub } from './jwk.js';
44-55-/** JWKS (JSON Web Key Set) */
66-export const jwksSchema = v.object({
77- keys: v.array(v.unknown()).chain((input, options) => {
88- // implementations SHOULD ignore JWKs within a JWK Set that use "kty"
99- // values that are not understood, are missing required members, or
1010- // have values out of the supported ranges.
1111- const keys: Jwk[] = [];
1212-1313- for (const item of input) {
1414- const result = jwkSchema.try(item, options);
1515- if (!result.ok) {
1616- continue;
1717- }
1818-1919- keys.push(result.value);
2020- }
2121-2222- return v.ok(keys);
2323- }),
2424-});
2525-2626-/** public JWKS (JSON Web Key Set with only public keys) */
2727-export const jwksPubSchema = v.object({
2828- keys: v.array(v.unknown()).chain((input, options) => {
2929- const keys: JwkPub[] = [];
3030-3131- for (const item of input) {
3232- const result = jwkPubSchema.try(item, options);
3333- if (!result.ok) {
3434- continue;
3535- }
3636-3737- keys.push(result.value);
3838- }
3939-4040- return v.ok(keys);
4141- }),
4242-});
4343-4444-export type Jwks = v.Infer<typeof jwksSchema>;
4545-export type JwksPub = v.Infer<typeof jwksPubSchema>;
···11-import * as v from '@badrap/valita';
22-33-import { urlSchema } from './uri.js';
44-55-/**
66- * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2}
77- */
88-export const oauthAuthorizationDetailSchema = v.object({
99- type: v.string(),
1010- /**
1111- * an array of strings representing the location of the resource or RS. these
1212- * strings are typically URIs identifying the location of the RS.
1313- */
1414- locations: v.array(urlSchema).optional(),
1515- /**
1616- * an array of strings representing the kinds of actions to be taken at the
1717- * resource.
1818- */
1919- actions: v.array(v.string()).optional(),
2020- /**
2121- * an array of strings representing the kinds of data being requested from the
2222- * resource.
2323- */
2424- datatypes: v.array(v.string()).optional(),
2525- /**
2626- * a string identifier indicating a specific resource available at the API.
2727- */
2828- identifier: v.string().optional(),
2929- /**
3030- * an array of strings representing the types or levels of privilege being
3131- * requested at the resource.
3232- */
3333- privileges: v.array(v.string()).optional(),
3434-});
3535-3636-export type OAuthAuthorizationDetail = v.Infer<typeof oauthAuthorizationDetailSchema>;
3737-3838-/**
3939- * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2}
4040- */
4141-export const oauthAuthorizationDetailsSchema = v.array(oauthAuthorizationDetailSchema);
4242-4343-export type OAuthAuthorizationDetails = v.Infer<typeof oauthAuthorizationDetailsSchema>;
···11-import * as v from '@badrap/valita';
22-33-import { oauthClientIdSchema } from './oauth-client-id.js';
44-import { httpsUriSchema } from './uri.js';
55-import { extractUrlPath, isHostnameIP } from './utils.js';
66-77-/**
88- * @see {@link https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html}
99- */
1010-export const oauthClientIdDiscoverableSchema = v.string().chain((input, options) => {
1111- // first validate as base client ID
1212- const clientIdResult = oauthClientIdSchema.try(input, options);
1313- if (!clientIdResult.ok) {
1414- return clientIdResult;
1515- }
1616-1717- // then validate as https URI
1818- const httpsResult = httpsUriSchema.try(input, options);
1919- if (!httpsResult.ok) {
2020- return httpsResult;
2121- }
2222-2323- const url = new URL(input);
2424-2525- if (url.username || url.password) {
2626- return v.err(`client ID must not contain credentials`);
2727- }
2828-2929- if (url.hash) {
3030- return v.err(`client ID must not contain a fragment`);
3131- }
3232-3333- if (url.pathname === '/') {
3434- return v.err(`client ID must contain a path component (e.g. "/client-metadata.json")`);
3535- }
3636-3737- if (url.pathname.endsWith('/')) {
3838- return v.err(`client ID path must not end with a trailing slash`);
3939- }
4040-4141- if (isHostnameIP(url.hostname)) {
4242- return v.err(`client ID hostname must not be an IP address`);
4343- }
4444-4545- // URL constructor normalizes the URL, so we extract the path manually to
4646- // avoid normalization, then compare it to the normalized path to ensure
4747- // that the URL does not contain path traversal or other unexpected characters
4848- if (extractUrlPath(input) !== url.pathname) {
4949- return v.err(`client ID must be in canonical form ("${url.href}", got "${input}")`);
5050- }
5151-5252- return v.ok(input);
5353-});
···11-import * as v from '@badrap/valita';
22-33-/** base OAuth client ID (any non-empty string) */
44-export const oauthClientIdSchema = v.string().assert((input) => input.length > 0, `must not be empty`);
55-66-export type OAuthClientId = v.Infer<typeof oauthClientIdSchema>;
···11-import * as v from '@badrap/valita';
22-33-export const oauthCodeChallengeMethodSchema = v.union(v.literal('S256'), v.literal('plain'));
44-55-export type OAuthCodeChallengeMethod = v.Infer<typeof oauthCodeChallengeMethodSchema>;
···11-import * as v from '@badrap/valita';
22-33-import { webUriSchema } from './uri.js';
44-55-export const oauthIssuerIdentifierSchema = webUriSchema.chain((input) => {
66- // validate the issuer (MIX-UP attacks)
77-88- if (input.endsWith('/')) {
99- return v.err(`issuer URL must not end with a slash`);
1010- }
1111-1212- const url = new URL(input);
1313-1414- if (url.username || url.password) {
1515- return v.err(`issuer URL must not contain a username or password`);
1616- }
1717-1818- if (url.hash || url.search) {
1919- return v.err(`issuer URL must not contain a query or fragment`);
2020- }
2121-2222- const canonicalValue = url.pathname === '/' ? url.origin : url.href;
2323- if (input !== canonicalValue) {
2424- return v.err(`issuer URL must be in the canonical form`);
2525- }
2626-2727- return v.ok(input);
2828-});
2929-3030-export type OAuthIssuerIdentifier = v.Infer<typeof oauthIssuerIdentifierSchema>;
···11-import * as v from '@badrap/valita';
22-33-import { oauthIssuerIdentifierSchema } from './oauth-issuer-identifier.js';
44-import { webUriSchema } from './uri.js';
55-66-export const oauthBearerMethodSchema = v.union(v.literal('header'), v.literal('body'), v.literal('query'));
77-88-export type OAuthBearerMethod = v.Infer<typeof oauthBearerMethodSchema>;
99-1010-/**
1111- * @see {@link https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2}
1212- */
1313-export const oauthProtectedResourceMetadataSchema = v.object({
1414- /**
1515- * REQUIRED. the protected resource's resource identifier, which is a URL that
1616- * uses the https scheme and has no query or fragment components.
1717- */
1818- resource: webUriSchema,
1919-2020- /**
2121- * OPTIONAL. JSON array containing a list of OAuth authorization server issuer
2222- * identifiers, as defined in RFC8414, for authorization servers that can be
2323- * used with this protected resource.
2424- */
2525- authorization_servers: v.array(oauthIssuerIdentifierSchema).optional(),
2626-2727- /**
2828- * OPTIONAL. URL of the protected resource's JWK Set document.
2929- */
3030- jwks_uri: webUriSchema.optional(),
3131-3232- /**
3333- * RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that
3434- * are used in authorization requests to request access to this protected resource.
3535- */
3636- scopes_supported: v.array(v.string()).optional(),
3737-3838- /**
3939- * OPTIONAL. JSON array containing a list of the supported methods of sending
4040- * an OAuth 2.0 Bearer Token to the protected resource.
4141- */
4242- bearer_methods_supported: v.array(oauthBearerMethodSchema).optional(),
4343-4444- /**
4545- * OPTIONAL. JSON array containing a list of the JWS signing algorithms
4646- * supported by the protected resource for signing resource responses.
4747- */
4848- resource_signing_alg_values_supported: v.array(v.string()).optional(),
4949-5050- /**
5151- * OPTIONAL. URL of a page containing human-readable information that
5252- * developers might want or need to know when using the protected resource.
5353- */
5454- resource_documentation: webUriSchema.optional(),
5555-5656- /**
5757- * OPTIONAL. URL that the protected resource provides to read about the
5858- * protected resource's requirements on how the client can use the data.
5959- */
6060- resource_policy_uri: webUriSchema.optional(),
6161-6262- /**
6363- * OPTIONAL. URL that the protected resource provides to read about the
6464- * protected resource's terms of service.
6565- */
6666- resource_tos_uri: webUriSchema.optional(),
6767-});
6868-6969-export const oauthProtectedResourceMetadataValidator = oauthProtectedResourceMetadataSchema.chain((data) => {
7070- const url = new URL(data.resource);
7171-7272- if (url.search) {
7373- return v.err({
7474- message: `resource URL must not contain query parameters`,
7575- path: ['resource'],
7676- });
7777- }
7878-7979- if (url.hash) {
8080- return v.err({
8181- message: `resource URL must not contain a fragment`,
8282- path: ['resource'],
8383- });
8484- }
8585-8686- return v.ok(data);
8787-});
8888-8989-export type OAuthProtectedResourceMetadata = v.Infer<typeof oauthProtectedResourceMetadataSchema>;
···11-import * as v from '@badrap/valita';
22-33-import { httpsUriSchema, loopbackUriSchema, privateUseUriSchema } from './uri.js';
44-55-/**
66- * this is a loopback URI with the additional restriction that the hostname
77- * `localhost` is not allowed.
88- *
99- * @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-8.3 Loopback Redirect Considerations} RFC8252
1010- *
1111- * > While redirect URIs using localhost (i.e.,
1212- * > "http://localhost:{port}/{path}") function similarly to loopback IP
1313- * > redirects described in Section 7.3, the use of localhost is NOT
1414- * > RECOMMENDED. Specifying a redirect URI with the loopback IP literal rather
1515- * > than localhost avoids inadvertently listening on network interfaces other
1616- * > than the loopback interface. It is also less susceptible to client-side
1717- * > firewalls and misconfigured host name resolution on the user's device.
1818- */
1919-export const loopbackRedirectUriSchema = loopbackUriSchema.chain((input) => {
2020- if (input.startsWith('http://localhost')) {
2121- return v.err(
2222- `use of "localhost" hostname is not allowed (RFC 8252), use a loopback IP such as "127.0.0.1" instead`,
2323- );
2424- }
2525- return v.ok(input);
2626-});
2727-2828-export type LoopbackRedirectUri = v.Infer<typeof loopbackRedirectUriSchema>;
2929-3030-export const oauthRedirectUriSchema = v.string().chain((input, options) => {
3131- if (input.startsWith('http://')) {
3232- return loopbackRedirectUriSchema.try(input, options);
3333- }
3434-3535- if (input.startsWith('https://')) {
3636- return httpsUriSchema.try(input, options);
3737- }
3838-3939- return privateUseUriSchema.try(input, options);
4040-});
4141-4242-export type OAuthRedirectUri = v.Infer<typeof oauthRedirectUriSchema>;
···11-import * as v from '@badrap/valita';
22-33-/** token type (case-insensitive input, normalized output) */
44-export const oauthTokenTypeSchema = v.string().chain((input) => {
55- const lower = input.toLowerCase();
66- if (lower === 'dpop') {
77- return v.ok('DPoP');
88- }
99- if (lower === 'bearer') {
1010- return v.ok('Bearer');
1111- }
1212- return v.err(`must be "DPoP" or "Bearer"`);
1313-});
1414-1515-export type OAuthTokenType = v.Infer<typeof oauthTokenTypeSchema>;
-100
packages/oauth/node-client/lib/schemas/uri.ts
···11-import * as v from '@badrap/valita';
22-33-import { isHostnameIP, isLocalHostname, isLoopbackHost } from './utils.js';
44-55-/**
66- * valid, but potentially dangerous URL (`data:`, `file:`, `javascript:`, etc.).
77- *
88- * any value that matches this schema is safe to parse using `new URL()`.
99- */
1010-export const urlSchema = v.string().chain((input) => {
1111- if (input.includes(':') && URL.canParse(input)) {
1212- return v.ok(input);
1313- }
1414- return v.err(`must be a valid url`);
1515-});
1616-1717-/** loopback URL (http://localhost, http://127.0.0.1, http://[::1]) */
1818-export const loopbackUriSchema = urlSchema.chain((input) => {
1919- if (!input.startsWith('http://')) {
2020- return v.err(`loopback url must use http: protocol`);
2121- }
2222-2323- const url = new URL(input);
2424- if (!isLoopbackHost(url.hostname)) {
2525- return v.err(`loopback url must use localhost, 127.0.0.1, or [::1] as hostname`);
2626- }
2727-2828- return v.ok(input);
2929-});
3030-3131-/** HTTPS URL with additional restrictions */
3232-export const httpsUriSchema = urlSchema.chain((input) => {
3333- if (!input.startsWith('https://')) {
3434- return v.err(`url must use https: protocol`);
3535- }
3636-3737- const url = new URL(input);
3838-3939- if (isLoopbackHost(url.hostname)) {
4040- return v.err(`https url must not use a loopback host`);
4141- }
4242-4343- if (!isHostnameIP(url.hostname)) {
4444- if (!url.hostname.includes('.')) {
4545- return v.err(`domain name must contain at least two segments`);
4646- }
4747- if (url.hostname.endsWith('.local')) {
4848- return v.err(`domain name must not end with .local`);
4949- }
5050- }
5151-5252- return v.ok(input);
5353-});
5454-5555-/** web URL (either loopback http or https) */
5656-export const webUriSchema = urlSchema.chain((input, options) => {
5757- if (input.startsWith('http://')) {
5858- return loopbackUriSchema.try(input, options);
5959- }
6060-6161- if (input.startsWith('https://')) {
6262- return httpsUriSchema.try(input, options);
6363- }
6464-6565- return v.err(`url must use http: or https: protocol`);
6666-});
6767-6868-/** web URL with a non-local hostname */
6969-export const nonLocalWebUriSchema = webUriSchema.chain((input) => {
7070- const url = new URL(input);
7171- if (isLocalHostname(url.hostname)) {
7272- return v.err(`hostname is invalid`);
7373- }
7474- return v.ok(input);
7575-});
7676-7777-/** private-use URI scheme (e.g., com.example.app:/callback) */
7878-export const privateUseUriSchema = urlSchema.chain((input) => {
7979- const dotIdx = input.indexOf('.');
8080- const colonIdx = input.indexOf(':');
8181-8282- if (dotIdx === -1 || colonIdx === -1 || dotIdx > colonIdx) {
8383- return v.err(`private-use uri scheme must contain a dot in the protocol`);
8484- }
8585-8686- const url = new URL(input);
8787- const scheme = url.protocol.slice(0, -1);
8888- const domain = scheme.split('.').reverse().join('.');
8989-9090- if (isLocalHostname(domain)) {
9191- return v.err(`private-use uri scheme must not be a local hostname`);
9292- }
9393-9494- // RFC 8252: private-use URIs must use single slash after scheme
9595- if (url.href.startsWith(`${url.protocol}//`) || url.username || url.password || url.hostname || url.port) {
9696- return v.err(`private-use uri must be in the form scheme:/<path>`);
9797- }
9898-9999- return v.ok(input);
100100-});
-113
packages/oauth/node-client/lib/schemas/utils.ts
···11-/**
22- * checks if a hostname is a loopback address
33- */
44-export const isLoopbackHost = (hostname: string): boolean => {
55- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
66-};
77-88-/**
99- * checks if a hostname is an IP address (IPv4 or IPv6)
1010- */
1111-export const isHostnameIP = (hostname: string): boolean => {
1212- // IPv4
1313- if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
1414- return true;
1515- }
1616- // IPv6
1717- if (hostname.startsWith('[') && hostname.endsWith(']')) {
1818- return true;
1919- }
2020- return false;
2121-};
2222-2323-/**
2424- * checks if a hostname is a local/reserved hostname
2525- *
2626- * returns true for single-segment hostnames and reserved TLDs
2727- */
2828-export const isLocalHostname = (hostname: string): boolean => {
2929- const parts = hostname.split('.');
3030- if (parts.length < 2) {
3131- return true;
3232- }
3333-3434- const tld = parts.at(-1)!.toLowerCase();
3535- return tld === 'test' || tld === 'local' || tld === 'localhost' || tld === 'invalid' || tld === 'example';
3636-};
3737-3838-/**
3939- * extracts the path from a URL without relying on URL constructor normalization
4040- *
4141- * this is needed because the URL constructor normalizes paths (e.g., removes `.` and `..` segments),
4242- * which can be used to bypass validation checks
4343- */
4444-export const extractUrlPath = (url: string): string => {
4545- const endOfProtocol = url.startsWith('https://') ? 8 : url.startsWith('http://') ? 7 : -1;
4646- if (endOfProtocol === -1) {
4747- throw new TypeError(`url must use https: or http: protocol`);
4848- }
4949-5050- const hashIdx = url.indexOf('#', endOfProtocol);
5151- const questionIdx = url.indexOf('?', endOfProtocol);
5252-5353- const queryStrIdx = questionIdx !== -1 && (hashIdx === -1 || questionIdx < hashIdx) ? questionIdx : -1;
5454-5555- const pathEnd =
5656- hashIdx === -1
5757- ? queryStrIdx === -1
5858- ? url.length
5959- : queryStrIdx
6060- : queryStrIdx === -1
6161- ? hashIdx
6262- : Math.min(hashIdx, queryStrIdx);
6363-6464- const slashIdx = url.indexOf('/', endOfProtocol);
6565- const pathStart = slashIdx === -1 || slashIdx > pathEnd ? pathEnd : slashIdx;
6666-6767- if (endOfProtocol === pathStart) {
6868- throw new TypeError(`url must contain a host`);
6969- }
7070-7171- return url.substring(pathStart, pathEnd) || '/';
7272-};
7373-7474-/**
7575- * checks if an item is the last occurrence in an array (for duplicate detection)
7676- */
7777-export const isLastOccurrence = <T>(item: T, index: number, array: readonly T[]): boolean => {
7878- return array.lastIndexOf(item) === index;
7979-};
8080-8181-/**
8282- * checks if a space-separated string contains a specific value
8383- *
8484- * optimized version of `input.split(' ').includes(value)`
8585- */
8686-export const isSpaceSeparatedValue = (value: string, input: string): boolean => {
8787- const inputLength = input.length;
8888- const valueLength = value.length;
8989-9090- if (inputLength < valueLength) {
9191- return false;
9292- }
9393-9494- let idx = input.indexOf(value);
9595- let idxEnd: number;
9696-9797- while (idx !== -1) {
9898- idxEnd = idx + valueLength;
9999-100100- if (
101101- // at beginning or preceded by space
102102- (idx === 0 || input.charCodeAt(idx - 1) === 32) &&
103103- // at end or followed by space
104104- (idxEnd === inputLength || input.charCodeAt(idxEnd) === 32)
105105- ) {
106106- return true;
107107- }
108108-109109- idx = input.indexOf(value, idxEnd + 1);
110110- }
111111-112112- return false;
113113-};
+1-2
packages/oauth/node-client/lib/types/token-set.ts
···11import type { Did } from '@atcute/lexicons';
22-33-import type { AtprotoOAuthScope } from '../schemas/atproto-oauth-scope.js';
22+import type { AtprotoOAuthScope } from '@atcute/oauth-types';
4354/**
65 * token set returned from token operations (exchange, refresh).