···11+# @atcute/oauth-node-client example
22+33+this example demonstrates OAuth authentication for AT Protocol using `@atcute/oauth-node-client`.
44+55+## requirements
66+77+confidential OAuth clients must be accessible via **https** since the authorization server (e.g.
88+Bluesky's PDS) needs to fetch the client's JWKS from the client_id URL.
99+1010+for local development, use a tunneling service like [ngrok](https://ngrok.com/),
1111+[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/),
1212+or similar.
1313+1414+## setup
1515+1616+install dependencies:
1717+1818+```sh
1919+bun install
2020+```
2121+2222+create `.env.local` and generate a fresh private key:
2323+2424+```sh
2525+bun run setup:env
2626+```
2727+2828+then edit `.env.local` and set `PUBLIC_URL` to your public https url.
2929+3030+## running with ngrok
3131+3232+1. start ngrok tunnel:
3333+3434+```sh
3535+ngrok http 3000
3636+```
3737+3838+2. copy the https URL (e.g. `https://abc123.ngrok.io`)
3939+4040+3. set `PUBLIC_URL` in `.env.local`, or start the server with the public URL:
4141+4242+```sh
4343+PUBLIC_URL=https://abc123.ngrok.io bun run dev
4444+```
4545+4646+4. open the ngrok URL in your browser
4747+4848+## environment variables
4949+5050+- `PUBLIC_URL` (required) - the https URL where this app is accessible
5151+- `PORT` (optional) - local listen port (default: `3000`)
5252+- `PRIVATE_KEY_JWK` (required) - JSON Web Key used for client authentication (`private_key_jwt`)
5353+- `COOKIE_SECRET` (optional) - secret for signed cookies. `setup:env` generates one; if unset, a secret is derived from `PRIVATE_KEY_JWK`
5454+5555+## generating a private key
5656+5757+the `setup:env` script generates a fresh key and writes it into `.env.local`. to rotate it, run:
5858+5959+```sh
6060+bun run setup:env
6161+```
6262+6363+for production, generate and store a persistent key:
6464+6565+```js
6666+import { generatePrivateKey, exportJwkKey } from '@atcute/oauth-node-client';
6767+6868+const key = await generatePrivateKey('main', 'ES256');
6969+const jwk = await exportJwkKey(key);
7070+console.log(JSON.stringify(jwk));
7171+```
7272+7373+then set `PRIVATE_KEY_JWK` to the output.
7474+7575+## routes
7676+7777+- `/` - home page with login form
7878+- `/oauth/login` - starts OAuth authorization flow
7979+- `/oauth/callback` - OAuth callback handler
8080+- `/protected` - example protected resource (fetches session info)
8181+- `/logout` - revokes tokens and clears session
8282+- `/client-metadata.json` - serves client metadata for discovery
8383+- `/jwks.json` - serves client jwks (public keys) for discovery
···11+import { type JWK, exportJWK, generateKeyPair } from 'jose';
22+33+/**
44+ * preferred algorithm order for DPoP key generation.
55+ * ES256K > ES (shorter first) > PS (shorter first) > RS (shorter first)
66+ */
77+const PREFERRED_ALGORITHMS = [
88+ 'ES256K',
99+ 'ES256',
1010+ 'ES384',
1111+ 'ES512',
1212+ 'PS256',
1313+ 'PS384',
1414+ 'PS512',
1515+ 'RS256',
1616+ 'RS384',
1717+ 'RS512',
1818+] as const;
1919+2020+/**
2121+ * sorts algorithms by preference order.
2222+ * ES256K > ES (shorter first) > PS (shorter first) > RS (shorter first) > other
2323+ */
2424+const sortAlgorithms = (algs: readonly string[]): string[] => {
2525+ return [...algs].sort((a, b) => {
2626+ const aIdx = PREFERRED_ALGORITHMS.indexOf(a as (typeof PREFERRED_ALGORITHMS)[number]);
2727+ const bIdx = PREFERRED_ALGORITHMS.indexOf(b as (typeof PREFERRED_ALGORITHMS)[number]);
2828+2929+ // known algorithms come before unknown
3030+ if (aIdx === -1 && bIdx === -1) {
3131+ return 0;
3232+ }
3333+ if (aIdx === -1) {
3434+ return 1;
3535+ }
3636+ if (bIdx === -1) {
3737+ return -1;
3838+ }
3939+4040+ return aIdx - bIdx;
4141+ });
4242+};
4343+4444+/**
4545+ * generates a new DPoP key (private JWK with `alg` set).
4646+ *
4747+ * @param supportedAlgs algorithms supported by the server (from `dpop_signing_alg_values_supported`)
4848+ * @returns private JWK with `alg` field set
4949+ */
5050+export const generateDpopKey = async (supportedAlgs?: readonly string[]): Promise<JWK> => {
5151+ // default to ES256 per atproto spec
5252+ const algs = supportedAlgs?.length ? sortAlgorithms(supportedAlgs) : ['ES256'];
5353+5454+ const errors: unknown[] = [];
5555+5656+ for (const alg of algs) {
5757+ try {
5858+ const { privateKey } = await generateKeyPair(alg, { extractable: true });
5959+6060+ // export to JWK for storage
6161+ const jwk = await exportJWK(privateKey);
6262+ jwk.alg = alg;
6363+6464+ return jwk;
6565+ } catch (err) {
6666+ errors.push(err);
6767+ }
6868+ }
6969+7070+ throw new AggregateError(errors, `failed to generate DPoP key for any of: ${algs.join(', ')}`);
7171+};
+93
packages/oauth/node-client/lib/errors.ts
···11+/**
22+ * thrown when client authentication method is no longer usable
33+ * (e.g., key removed from keyset or server no longer supports method).
44+ */
55+export class AuthMethodUnsatisfiableError extends Error {
66+ override name = 'AuthMethodUnsatisfiableError';
77+}
88+99+/**
1010+ * thrown when a session is invalid and cannot be used.
1111+ */
1212+export class TokenInvalidError extends Error {
1313+ override name = 'TokenInvalidError';
1414+1515+ constructor(
1616+ public readonly sub: string,
1717+ message = `session for "${sub}" is invalid`,
1818+ options?: ErrorOptions,
1919+ ) {
2020+ super(message, options);
2121+ }
2222+}
2323+2424+/**
2525+ * thrown when token refresh fails.
2626+ */
2727+export class TokenRefreshError extends Error {
2828+ override name = 'TokenRefreshError';
2929+3030+ constructor(
3131+ public readonly sub: string,
3232+ message: string,
3333+ options?: ErrorOptions,
3434+ ) {
3535+ super(message, options);
3636+ }
3737+}
3838+3939+/**
4040+ * thrown when a session has been revoked.
4141+ */
4242+export class TokenRevokedError extends Error {
4343+ override name = 'TokenRevokedError';
4444+4545+ constructor(
4646+ public readonly sub: string,
4747+ message = `session for "${sub}" was revoked`,
4848+ options?: ErrorOptions,
4949+ ) {
5050+ super(message, options);
5151+ }
5252+}
5353+5454+/**
5555+ * thrown when OAuth response indicates an error.
5656+ */
5757+export class OAuthResponseError extends Error {
5858+ override name = 'OAuthResponseError';
5959+6060+ constructor(
6161+ public readonly response: Response,
6262+ public readonly error: string,
6363+ public readonly errorDescription?: string,
6464+ ) {
6565+ super(errorDescription ?? error);
6666+ }
6767+6868+ get status(): number {
6969+ return this.response.status;
7070+ }
7171+}
7272+7373+/**
7474+ * thrown when OAuth callback contains an error.
7575+ */
7676+export class OAuthCallbackError extends Error {
7777+ override name = 'OAuthCallbackError';
7878+7979+ constructor(
8080+ public readonly error: string,
8181+ public readonly errorDescription?: string,
8282+ public readonly state?: string,
8383+ ) {
8484+ super(errorDescription ?? error);
8585+ }
8686+}
8787+8888+/**
8989+ * thrown when metadata resolution fails.
9090+ */
9191+export class OAuthResolverError extends Error {
9292+ override name = 'OAuthResolverError';
9393+}
+52
packages/oauth/node-client/lib/index.ts
···11+export { buildClientMetadata } from './build-client-metadata.js';
22+export {
33+ OAuthClient,
44+ type AuthorizationResult,
55+ type AuthorizeOptions,
66+ type AuthorizeTarget,
77+ type CallbackOptions,
88+ type CallbackResult,
99+ type OAuthClientOptions,
1010+ type OAuthClientStores,
1111+ type RestoreOptions,
1212+} from './oauth-client.js';
1313+1414+export { OAuthSession } from './oauth-session.js';
1515+export type { SessionEvent, SessionEventListener } from './session-getter.js';
1616+1717+export {
1818+ exportJwkKey,
1919+ exportPkcs8Key,
2020+ generatePrivateKey,
2121+ importJwkKey,
2222+ importPkcs8Key,
2323+} from './keyset/import-key.js';
2424+export { Keyset } from './keyset/keyset.js';
2525+export type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './keyset/types.js';
2626+2727+export {
2828+ AuthMethodUnsatisfiableError,
2929+ OAuthCallbackError,
3030+ OAuthResolverError,
3131+ OAuthResponseError,
3232+ TokenInvalidError,
3333+ TokenRefreshError,
3434+ TokenRevokedError,
3535+} from './errors.js';
3636+3737+export type { LockFunction } from './utils/lock.js';
3838+export { MemoryStore } from './utils/memory-store.js';
3939+export type { Store } from './utils/store.js';
4040+4141+export type { DpopNonceCache } from './dpop/fetch-dpop.js';
4242+export type { AuthorizationServerMetadataCache } from './resolvers/authorization-server-metadata.js';
4343+export type { ProtectedResourceMetadataCache } from './resolvers/protected-resource-metadata.js';
4444+export type { SessionStore, StoredSession } from './types/sessions.js';
4545+export type { StateStore, StoredState } from './types/states.js';
4646+export type { TokenSet } from './types/token-set.js';
4747+4848+export type { ConfidentialClientMetadata } from './schemas/atcute-confidential-client-metadata.js';
4949+export type { AtprotoAuthorizationServerMetadata } from './schemas/atproto-authorization-server-metadata.js';
5050+export type { AtprotoProtectedResourceMetadata } from './schemas/atproto-protected-resource-metadata.js';
5151+export type { OAuthClientMetadata } from './schemas/oauth-client-metadata.js';
5252+export type { OAuthResponseMode } from './schemas/oauth-response-mode.js';
···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+};
+206
packages/oauth/node-client/lib/session-getter.ts
···11+import type { Did } from '@atcute/lexicons';
22+33+import {
44+ AuthMethodUnsatisfiableError,
55+ OAuthResponseError,
66+ TokenInvalidError,
77+ TokenRefreshError,
88+ TokenRevokedError,
99+} from './errors.js';
1010+import type { OAuthServerFactory } from './oauth-server-factory.js';
1111+import type { SessionStore, StoredSession } from './types/sessions.js';
1212+import { CachedGetter, type GetCachedOptions } from './utils/cached-getter.js';
1313+import { type LockFunction, requestLock as defaultRequestLock } from './utils/lock.js';
1414+1515+export type { SessionStore, StoredSession };
1616+1717+export type SessionEventType = 'updated' | 'deleted';
1818+1919+export interface SessionUpdatedEvent {
2020+ type: 'updated';
2121+ sub: Did;
2222+ session: StoredSession;
2323+}
2424+2525+export interface SessionDeletedEvent {
2626+ type: 'deleted';
2727+ sub: Did;
2828+ cause: unknown;
2929+}
3030+3131+export type SessionEvent = SessionUpdatedEvent | SessionDeletedEvent;
3232+3333+export type SessionEventListener = (event: SessionEvent) => void;
3434+3535+export interface SessionGetterOptions {
3636+ /** session store */
3737+ sessionStore: SessionStore;
3838+ /** server factory for creating OAuthServerAgent */
3939+ serverFactory: OAuthServerFactory;
4040+ /** lock function for preventing concurrent refresh */
4141+ requestLock?: LockFunction;
4242+}
4343+4444+/**
4545+ * manages session retrieval and automatic token refresh.
4646+ *
4747+ * wraps a session store with caching and staleness checking.
4848+ * automatically refreshes tokens when they're about to expire.
4949+ */
5050+export class SessionGetter extends CachedGetter<Did, StoredSession> {
5151+ private readonly listeners = new Set<SessionEventListener>();
5252+ private readonly requestLock: LockFunction;
5353+5454+ constructor(options: SessionGetterOptions) {
5555+ const { sessionStore, serverFactory, requestLock = defaultRequestLock } = options;
5656+5757+ super(
5858+ // getter function - refreshes the token
5959+ async (sub, opts, storedSession) => {
6060+ if (storedSession === undefined) {
6161+ const cause = new TokenRefreshError(sub, 'session was deleted by another process');
6262+ this.dispatchEvent({ type: 'deleted', sub, cause });
6363+ throw cause;
6464+ }
6565+6666+ const { dpopKey, authMethod, tokenSet } = storedSession;
6767+6868+ if (sub !== tokenSet.sub) {
6969+ throw new TokenRefreshError(sub, 'stored session sub mismatch');
7070+ }
7171+7272+ if (!tokenSet.refresh_token) {
7373+ throw new TokenRefreshError(sub, 'no refresh token available');
7474+ }
7575+7676+ const server = await serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey);
7777+7878+ // don't abort after this point - refresh tokens are single-use
7979+ opts.signal?.throwIfAborted();
8080+8181+ try {
8282+ const newTokenSet = await server.refresh(tokenSet);
8383+8484+ if (sub !== newTokenSet.sub) {
8585+ throw new TokenRefreshError(sub, 'token set sub mismatch after refresh');
8686+ }
8787+8888+ return {
8989+ dpopKey,
9090+ authMethod: server.authMethod,
9191+ tokenSet: newTokenSet,
9292+ };
9393+ } catch (cause) {
9494+ // invalid_grant means token was revoked or already used
9595+ if (
9696+ cause instanceof OAuthResponseError &&
9797+ cause.status === 400 &&
9898+ cause.error === 'invalid_grant'
9999+ ) {
100100+ const msg = cause.errorDescription ?? 'session was revoked';
101101+ throw new TokenRefreshError(sub, msg, { cause });
102102+ }
103103+104104+ throw cause;
105105+ }
106106+ },
107107+ sessionStore,
108108+ {
109109+ isStale(_sub, { tokenSet }) {
110110+ if (tokenSet.expires_at == null) {
111111+ return false;
112112+ }
113113+ // refresh if token expires within 10-40 seconds (randomized to reduce concurrent refreshes)
114114+ const buffer = 10_000 + 30_000 * Math.random();
115115+ return tokenSet.expires_at < Date.now() + buffer;
116116+ },
117117+ async onStoreError(err, _sub, { tokenSet, dpopKey, authMethod }) {
118118+ if (!(err instanceof AuthMethodUnsatisfiableError)) {
119119+ try {
120120+ const server = await serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey);
121121+ await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token);
122122+ } catch {
123123+ // ignore revocation errors
124124+ }
125125+ }
126126+ throw err;
127127+ },
128128+ deleteOnError(err) {
129129+ return (
130130+ err instanceof TokenRefreshError ||
131131+ err instanceof TokenRevokedError ||
132132+ err instanceof TokenInvalidError ||
133133+ err instanceof AuthMethodUnsatisfiableError
134134+ );
135135+ },
136136+ },
137137+ );
138138+139139+ this.requestLock = requestLock;
140140+ }
141141+142142+ /**
143143+ * adds a listener for session events.
144144+ */
145145+ addEventListener(listener: SessionEventListener): void {
146146+ this.listeners.add(listener);
147147+ }
148148+149149+ /**
150150+ * removes a session event listener.
151151+ */
152152+ removeEventListener(listener: SessionEventListener): void {
153153+ this.listeners.delete(listener);
154154+ }
155155+156156+ private dispatchEvent(event: SessionEvent): void {
157157+ for (const listener of this.listeners) {
158158+ try {
159159+ listener(event);
160160+ } catch {
161161+ // ignore listener errors
162162+ }
163163+ }
164164+ }
165165+166166+ override async setStored(sub: Did, session: StoredSession): Promise<void> {
167167+ if (sub !== session.tokenSet.sub) {
168168+ throw new TypeError('token set does not match the expected sub');
169169+ }
170170+ await super.setStored(sub, session);
171171+ this.dispatchEvent({ type: 'updated', sub, session });
172172+ }
173173+174174+ override async deleteStored(sub: Did, cause?: unknown): Promise<void> {
175175+ await super.deleteStored(sub, cause);
176176+ this.dispatchEvent({ type: 'deleted', sub, cause });
177177+ }
178178+179179+ /**
180180+ * gets a session, optionally forcing a refresh.
181181+ *
182182+ * @param sub user's DID
183183+ * @param refresh true to force refresh, false to allow stale, 'auto' for normal behavior
184184+ * @returns session data
185185+ */
186186+ async getSession(sub: Did, refresh: boolean | 'auto' = 'auto'): Promise<StoredSession> {
187187+ return this.get(sub, {
188188+ noCache: refresh === true,
189189+ allowStale: refresh === false,
190190+ });
191191+ }
192192+193193+ override async get(sub: Did, options?: GetCachedOptions): Promise<StoredSession> {
194194+ // use lock to prevent concurrent refresh for the same sub
195195+ const session = await this.requestLock(`oauth-session-${sub}`, async () => {
196196+ const signal = options?.signal ?? AbortSignal.timeout(30_000);
197197+ return super.get(sub, { ...options, signal });
198198+ });
199199+200200+ if (sub !== session.tokenSet.sub) {
201201+ throw new Error('token set does not match the expected sub');
202202+ }
203203+204204+ return session;
205205+ }
206206+}
···11+/**
22+ * function that acquires a lock by name and runs a callback.
33+ */
44+export type LockFunction = <T>(name: string, fn: () => Promise<T>) => Promise<T>;
55+66+const locks = new Map<string, Promise<void>>();
77+88+/**
99+ * acquires a lock by name, ensuring only one callback runs at a time per name.
1010+ *
1111+ * @param name lock identifier
1212+ * @returns release function
1313+ */
1414+const acquireLock = (name: string): Promise<() => void> => {
1515+ return new Promise((resolveAcquire) => {
1616+ const prev = locks.get(name) ?? Promise.resolve();
1717+ const next = prev.then(() => {
1818+ return new Promise<void>((resolveRelease) => {
1919+ const release = () => {
2020+ // only delete the lock if it is still the current one
2121+ if (locks.get(name) === next) {
2222+ locks.delete(name);
2323+ }
2424+ resolveRelease();
2525+ };
2626+ resolveAcquire(release);
2727+ });
2828+ });
2929+3030+ locks.set(name, next);
3131+ });
3232+};
3333+3434+/**
3535+ * runs a callback with an exclusive lock by name.
3636+ *
3737+ * ensures only one callback runs at a time for a given lock name.
3838+ * this is the default in-memory implementation for single-process use.
3939+ *
4040+ * @param name lock identifier
4141+ * @param fn callback to run while holding the lock
4242+ * @returns callback result
4343+ */
4444+export const requestLock: LockFunction = async (name, fn) => {
4545+ const release = await acquireLock(name);
4646+ try {
4747+ return await fn();
4848+ } finally {
4949+ release();
5050+ }
5151+};
+185
packages/oauth/node-client/lib/utils/lru.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import { LRUCache } from './lru.js';
44+55+describe('LRUCache', () => {
66+ describe('basic operations', () => {
77+ it('should set and get values', () => {
88+ const cache = new LRUCache<string, number>(3);
99+ cache.set('a', 1);
1010+ cache.set('b', 2);
1111+1212+ expect(cache.get('a')).toBe(1);
1313+ expect(cache.get('b')).toBe(2);
1414+ expect(cache.get('c')).toBeUndefined();
1515+ });
1616+1717+ it('should update existing values', () => {
1818+ const cache = new LRUCache<string, number>(3);
1919+ cache.set('a', 1);
2020+ cache.set('a', 10);
2121+2222+ expect(cache.get('a')).toBe(10);
2323+ });
2424+2525+ it('should delete values', () => {
2626+ const cache = new LRUCache<string, number>(3);
2727+ cache.set('a', 1);
2828+ cache.set('b', 2);
2929+3030+ expect(cache.delete('a')).toBe(true);
3131+ expect(cache.get('a')).toBeUndefined();
3232+ expect(cache.delete('a')).toBe(false);
3333+ expect(cache.get('b')).toBe(2);
3434+ });
3535+3636+ it('should clear all values', () => {
3737+ const cache = new LRUCache<string, number>(3);
3838+ cache.set('a', 1);
3939+ cache.set('b', 2);
4040+ cache.clear();
4141+4242+ expect(cache.get('a')).toBeUndefined();
4343+ expect(cache.get('b')).toBeUndefined();
4444+ });
4545+4646+ it('should check if key exists', () => {
4747+ const cache = new LRUCache<string, number>(3);
4848+ cache.set('a', 1);
4949+5050+ expect(cache.has('a')).toBe(true);
5151+ expect(cache.has('b')).toBe(false);
5252+ });
5353+ });
5454+5555+ describe('LRU eviction', () => {
5656+ it('should evict least recently used when at capacity', () => {
5757+ const cache = new LRUCache<string, number>(3);
5858+ cache.set('a', 1);
5959+ cache.set('b', 2);
6060+ cache.set('c', 3);
6161+ cache.set('d', 4); // should evict 'a'
6262+6363+ expect(cache.get('a')).toBeUndefined();
6464+ expect(cache.get('b')).toBe(2);
6565+ expect(cache.get('c')).toBe(3);
6666+ expect(cache.get('d')).toBe(4);
6767+ });
6868+6969+ it('should update LRU order on get', () => {
7070+ const cache = new LRUCache<string, number>(3);
7171+ cache.set('a', 1);
7272+ cache.set('b', 2);
7373+ cache.set('c', 3);
7474+7575+ cache.get('a'); // 'a' is now most recently used
7676+ cache.set('d', 4); // should evict 'b' (least recently used)
7777+7878+ expect(cache.get('a')).toBe(1);
7979+ expect(cache.get('b')).toBeUndefined();
8080+ expect(cache.get('c')).toBe(3);
8181+ expect(cache.get('d')).toBe(4);
8282+ });
8383+8484+ it('should update LRU order on set (existing key)', () => {
8585+ const cache = new LRUCache<string, number>(3);
8686+ cache.set('a', 1);
8787+ cache.set('b', 2);
8888+ cache.set('c', 3);
8989+9090+ cache.set('a', 10); // 'a' is now most recently used
9191+ cache.set('d', 4); // should evict 'b'
9292+9393+ expect(cache.get('a')).toBe(10);
9494+ expect(cache.get('b')).toBeUndefined();
9595+ });
9696+9797+ it('should not update LRU order on peek', () => {
9898+ const cache = new LRUCache<string, number>(3);
9999+ cache.set('a', 1);
100100+ cache.set('b', 2);
101101+ cache.set('c', 3);
102102+103103+ cache.peek('a'); // should NOT update LRU order
104104+ cache.set('d', 4); // should evict 'a' (still least recently used)
105105+106106+ expect(cache.peek('a')).toBeUndefined();
107107+ expect(cache.get('b')).toBe(2);
108108+ });
109109+ });
110110+111111+ describe('iteration', () => {
112112+ it('should iterate keys in LRU order (most to least recent)', () => {
113113+ const cache = new LRUCache<string, number>(5);
114114+ cache.set('a', 1);
115115+ cache.set('b', 2);
116116+ cache.set('c', 3);
117117+ cache.get('a'); // move 'a' to front
118118+119119+ const keys = [...cache.keys()];
120120+ expect(keys).toEqual(['a', 'c', 'b']);
121121+ });
122122+123123+ it('should iterate values in LRU order', () => {
124124+ const cache = new LRUCache<string, number>(5);
125125+ cache.set('a', 1);
126126+ cache.set('b', 2);
127127+ cache.set('c', 3);
128128+129129+ const values = [...cache.values()];
130130+ expect(values).toEqual([3, 2, 1]);
131131+ });
132132+133133+ it('should iterate entries in LRU order', () => {
134134+ const cache = new LRUCache<string, number>(5);
135135+ cache.set('a', 1);
136136+ cache.set('b', 2);
137137+138138+ const entries = [...cache.entries()];
139139+ expect(entries).toEqual([
140140+ ['b', 2],
141141+ ['a', 1],
142142+ ]);
143143+ });
144144+145145+ it('should be iterable with for-of', () => {
146146+ const cache = new LRUCache<string, number>(5);
147147+ cache.set('a', 1);
148148+ cache.set('b', 2);
149149+150150+ const entries: [string, number][] = [];
151151+ for (const entry of cache) {
152152+ entries.push(entry);
153153+ }
154154+155155+ expect(entries).toEqual([
156156+ ['b', 2],
157157+ ['a', 1],
158158+ ]);
159159+ });
160160+ });
161161+162162+ describe('edge cases', () => {
163163+ it('should handle cache of size 1', () => {
164164+ const cache = new LRUCache<string, number>(1);
165165+ cache.set('a', 1);
166166+ cache.set('b', 2);
167167+168168+ expect(cache.get('a')).toBeUndefined();
169169+ expect(cache.get('b')).toBe(2);
170170+ });
171171+172172+ it('should handle empty cache iteration', () => {
173173+ const cache = new LRUCache<string, number>(3);
174174+175175+ expect([...cache.keys()]).toEqual([]);
176176+ expect([...cache.values()]).toEqual([]);
177177+ expect([...cache.entries()]).toEqual([]);
178178+ });
179179+180180+ it('should report correct size', () => {
181181+ const cache = new LRUCache<string, number>(5);
182182+ expect(cache.size).toBe(5);
183183+ });
184184+ });
185185+});
+234
packages/oauth/node-client/lib/utils/lru.ts
···11+interface LRUNode<K, V> {
22+ key: K;
33+ value: V;
44+ prev: LRUNode<K, V> | null;
55+ next: LRUNode<K, V> | null;
66+}
77+88+/**
99+ * a least recently used (LRU) cache with fixed capacity
1010+ * evicts the least recently used items when capacity is exceeded
1111+ */
1212+export class LRUCache<K, V> {
1313+ readonly #size: number;
1414+ #count = 0;
1515+1616+ #map = new Map<K, LRUNode<K, V>>();
1717+ #head: LRUNode<K, V> | null = null;
1818+ #tail: LRUNode<K, V> | null = null;
1919+2020+ /**
2121+ * creates a new LRU cache with the specified capacity
2222+ * @param size the maximum number of items the cache can hold
2323+ */
2424+ constructor(size: number) {
2525+ this.#size = size;
2626+ }
2727+2828+ /** the maximum capacity of the cache */
2929+ get size(): number {
3030+ return this.#size;
3131+ }
3232+3333+ /**
3434+ * gets a value without affecting its position in the cache
3535+ * @param key the key to look up
3636+ * @returns the value associated with the key, or undefined if not found
3737+ */
3838+ peek(key: K): V | undefined {
3939+ const node = this.#map.get(key);
4040+ if (node === undefined) {
4141+ return undefined;
4242+ }
4343+4444+ return node.value;
4545+ }
4646+4747+ /**
4848+ * gets a value and marks it as most recently used
4949+ * @param key the key to look up
5050+ * @returns the value associated with the key, or undefined if not found
5151+ */
5252+ get(key: K): V | undefined {
5353+ const node = this.#map.get(key);
5454+ if (node === undefined) {
5555+ return undefined;
5656+ }
5757+5858+ this.#moveToFront(node);
5959+ return node.value;
6060+ }
6161+6262+ /**
6363+ * stores a value for the given key, marking it as most recently used
6464+ * evicts the least recently used item if the cache is at capacity
6565+ * @param key the key to store
6666+ * @param value the value to associate with the key
6767+ */
6868+ set(key: K, value: V): void {
6969+ {
7070+ const existing = this.#map.get(key);
7171+7272+ if (existing !== undefined) {
7373+ existing.value = value;
7474+ this.#moveToFront(existing);
7575+ return;
7676+ }
7777+ }
7878+7979+ {
8080+ const node: LRUNode<K, V> = { key, value, prev: null, next: null };
8181+ this.#map.set(key, node);
8282+ this.#addToFront(node);
8383+8484+ this.#count++;
8585+ }
8686+8787+ this.#evict();
8888+ }
8989+9090+ /**
9191+ * removes a key from the cache
9292+ * @param key the key to remove
9393+ * @returns true if the key was found and removed, false otherwise
9494+ */
9595+ delete(key: K): boolean {
9696+ const node = this.#map.get(key);
9797+ if (node === undefined) {
9898+ return false;
9999+ }
100100+101101+ this.#map.delete(key);
102102+ this.#removeNode(node);
103103+ this.#count--;
104104+ return true;
105105+ }
106106+107107+ /**
108108+ * removes all items from the cache
109109+ */
110110+ clear(): void {
111111+ this.#map.clear();
112112+ this.#head = null;
113113+ this.#tail = null;
114114+ this.#count = 0;
115115+ }
116116+117117+ /**
118118+ * checks if a key exists in the cache
119119+ * @param key the key to check
120120+ * @returns true if the key exists, false otherwise
121121+ */
122122+ has(key: K): boolean {
123123+ return this.#map.has(key);
124124+ }
125125+126126+ /**
127127+ * iterates over the keys in LRU order (most to least recently used)
128128+ * @returns iterator of keys
129129+ */
130130+ *keys(): IterableIterator<K> {
131131+ let current = this.#head;
132132+ while (current !== null) {
133133+ yield current.key;
134134+ current = current.next;
135135+ }
136136+ }
137137+138138+ /**
139139+ * iterates over the values in LRU order (most to least recently used)
140140+ * @returns iterator of values
141141+ */
142142+ *values(): IterableIterator<V> {
143143+ let current = this.#head;
144144+ while (current !== null) {
145145+ yield current.value;
146146+ current = current.next;
147147+ }
148148+ }
149149+150150+ /**
151151+ * iterates over the key-value pairs in LRU order (most to least recently used)
152152+ * @returns iterator of [key, value] tuples
153153+ */
154154+ *entries(): IterableIterator<[K, V]> {
155155+ let current = this.#head;
156156+ while (current !== null) {
157157+ yield [current.key, current.value];
158158+ current = current.next;
159159+ }
160160+ }
161161+162162+ [Symbol.iterator](): IterableIterator<[K, V]> {
163163+ return this.entries();
164164+ }
165165+166166+ #moveToFront(node: LRUNode<K, V>): void {
167167+ if (this.#head === node) {
168168+ return;
169169+ }
170170+171171+ if (node.prev !== null) {
172172+ node.prev.next = node.next;
173173+ }
174174+175175+ if (node.next !== null) {
176176+ node.next.prev = node.prev;
177177+ } else {
178178+ this.#tail = node.prev;
179179+ }
180180+181181+ node.prev = null;
182182+ node.next = this.#head;
183183+184184+ // Safe because this method is only called when head exists
185185+ this.#head!.prev = node;
186186+ this.#head = node;
187187+ }
188188+189189+ #addToFront(node: LRUNode<K, V>): void {
190190+ node.next = this.#head;
191191+ node.prev = null;
192192+193193+ if (this.#head !== null) {
194194+ this.#head.prev = node;
195195+ } else {
196196+ this.#tail = node;
197197+ }
198198+199199+ this.#head = node;
200200+ }
201201+202202+ #removeNode(node: LRUNode<K, V>): void {
203203+ if (node.prev !== null) {
204204+ node.prev.next = node.next;
205205+ } else {
206206+ this.#head = node.next;
207207+ }
208208+209209+ if (node.next !== null) {
210210+ node.next.prev = node.prev;
211211+ } else {
212212+ this.#tail = node.prev;
213213+ }
214214+ }
215215+216216+ #evict(): void {
217217+ const excess = this.#count - this.#size;
218218+ if (excess <= 0) {
219219+ return;
220220+ }
221221+222222+ let current: LRUNode<K, V> = this.#tail!;
223223+224224+ for (let i = 0; i < excess; i++) {
225225+ this.#map.delete(current.key);
226226+ current = current.prev!;
227227+ }
228228+229229+ current.next = null;
230230+ this.#tail = current;
231231+232232+ this.#count -= excess;
233233+ }
234234+}
···11+import type { Store } from './store.js';
22+33+import { LRUCache } from './lru.js';
44+55+export interface MemoryStoreOptions {
66+ /** maximum number of items the store can hold */
77+ maxSize?: number;
88+ /** time-to-live in milliseconds */
99+ ttl?: number;
1010+ /** whether to automatically purge expired entries */
1111+ ttlAutopurge?: boolean;
1212+}
1313+1414+interface Entry<V> {
1515+ value: V;
1616+ expiresAt: number;
1717+}
1818+1919+/**
2020+ * in-memory store with optional LRU eviction and TTL expiration.
2121+ *
2222+ * suitable for development, testing, or single-instance deployments.
2323+ * for production with multiple instances, use a shared store (e.g., Redis).
2424+ */
2525+export class MemoryStore<K, V> implements Store<K, V>, Disposable {
2626+ #map: LRUCache<K, Entry<V>> | Map<K, Entry<V>>;
2727+2828+ #ttlMs: number;
2929+ #ttlAutopurge: boolean;
3030+ #ttlTimer: ReturnType<typeof setTimeout> | undefined;
3131+3232+ /**
3333+ * creates a new in-memory store.
3434+ *
3535+ * @param options store configuration
3636+ */
3737+ constructor(options: MemoryStoreOptions = {}) {
3838+ this.#map = options.maxSize !== undefined ? new LRUCache(options.maxSize) : new Map();
3939+4040+ this.#ttlMs = options.ttl ?? 0;
4141+ this.#ttlAutopurge = options.ttlAutopurge ?? false;
4242+ }
4343+4444+ /** @inheritdoc */
4545+ get(key: K): V | undefined {
4646+ const entry = this.#map.get(key);
4747+ if (entry === undefined) {
4848+ return undefined;
4949+ }
5050+5151+ if (this.#ttlMs > 0 && Date.now() > entry.expiresAt) {
5252+ this.#map.delete(key);
5353+ return undefined;
5454+ }
5555+5656+ return entry.value;
5757+ }
5858+5959+ /** @inheritdoc */
6060+ set(key: K, value: V): void {
6161+ this.#map.set(key, {
6262+ value,
6363+ expiresAt: Date.now() + this.#ttlMs,
6464+ });
6565+6666+ if (this.#ttlAutopurge && this.#ttlTimer === undefined) {
6767+ this.#ttlTimer = setTimeout(() => this.#evict(), this.#ttlMs);
6868+ }
6969+ }
7070+7171+ /** @inheritdoc */
7272+ delete(key: K): void {
7373+ this.#map.delete(key);
7474+ }
7575+7676+ /** @inheritdoc */
7777+ clear(): void {
7878+ this.#map.clear();
7979+ }
8080+8181+ /**
8282+ * stops background timers and releases resources.
8383+ */
8484+ dispose(): void {
8585+ if (this.#ttlTimer !== undefined) {
8686+ clearTimeout(this.#ttlTimer);
8787+ this.#ttlTimer = undefined;
8888+ }
8989+ }
9090+9191+ [Symbol.dispose](): void {
9292+ this.dispose();
9393+ }
9494+9595+ #evict(): void {
9696+ this.#ttlTimer = undefined;
9797+9898+ const now = Date.now();
9999+ let earliest = Infinity;
100100+101101+ for (const [key, { expiresAt }] of this.#map) {
102102+ if (now > expiresAt) {
103103+ this.#map.delete(key);
104104+ } else if (expiresAt < earliest) {
105105+ earliest = expiresAt;
106106+ }
107107+ }
108108+109109+ if (earliest < Infinity) {
110110+ this.#ttlTimer = setTimeout(() => this.#evict(), earliest - now);
111111+ }
112112+ }
113113+}
+43
packages/oauth/node-client/lib/utils/store.ts
···11+import type { Awaitable } from '../types/misc.js';
22+33+/** options for store get operations */
44+export interface GetOptions {
55+ /** abort signal for cancellation */
66+ signal?: AbortSignal;
77+}
88+99+/**
1010+ * key-value store interface for sessions, states, and caches.
1111+ *
1212+ * implementations can be synchronous or asynchronous.
1313+ */
1414+export interface Store<K, V> {
1515+ /**
1616+ * gets a value by key.
1717+ *
1818+ * @param key lookup key
1919+ * @param options get options (e.g., abort signal)
2020+ * @returns value if found, undefined otherwise
2121+ */
2222+ get(key: K, options?: GetOptions): Awaitable<V | undefined>;
2323+2424+ /**
2525+ * sets a value for the given key.
2626+ *
2727+ * @param key storage key
2828+ * @param value value to store
2929+ */
3030+ set(key: K, value: V): Awaitable<void>;
3131+3232+ /**
3333+ * deletes a value by key.
3434+ *
3535+ * @param key key to delete
3636+ */
3737+ delete(key: K): Awaitable<void>;
3838+3939+ /**
4040+ * clears all entries from the store.
4141+ */
4242+ clear(): Awaitable<void>;
4343+}