···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/types/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/types/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/types/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/types/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+};