Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/shared/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from '@jest/globals';
import {
SafeUrlSchema,
OAuthMetadataSchema,
OpenIdProviderMetadataSchema,
OAuthClientMetadataSchema,
} from './auth.js';

describe('SafeUrlSchema', () => {
it('accepts valid HTTPS URLs', () => {
expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com');
expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize');
});

it('accepts valid HTTP URLs', () => {
expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000');
});

it('rejects javascript: scheme URLs', () => {
expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});

it('rejects invalid URLs', () => {
expect(() => SafeUrlSchema.parse('not-a-url')).toThrow();
expect(() => SafeUrlSchema.parse('')).toThrow();
});
});

describe('OAuthMetadataSchema', () => {
it('validates complete OAuth metadata', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
response_types_supported: ['code'],
scopes_supported: ['read', 'write'],
};

expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow();
});

it('rejects metadata with javascript: URLs', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'javascript:alert(1)',
token_endpoint: 'https://auth.example.com/oauth/token',
response_types_supported: ['code'],
};

expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});

it('requires mandatory fields', () => {
const incompleteMetadata = {
issuer: 'https://auth.example.com',
};

expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow();
});
});

describe('OpenIdProviderMetadataSchema', () => {
it('validates complete OpenID Provider metadata', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};

expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow();
});

it('rejects metadata with javascript: in jwks_uri', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
jwks_uri: 'javascript:alert(1)',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};

expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
});

describe('OAuthClientMetadataSchema', () => {
it('validates client metadata with safe URLs', () => {
const metadata = {
redirect_uris: ['https://app.example.com/callback'],
client_name: 'Test App',
client_uri: 'https://app.example.com',
};

expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow();
});

it('rejects client metadata with javascript: redirect URIs', () => {
const metadata = {
redirect_uris: ['javascript:alert(1)'],
client_name: 'Test App',
};

expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
});
56 changes: 38 additions & 18 deletions src/shared/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import { z } from "zod";

/**
* Reusable URL validation that disallows javascript: scheme
*/
export const SafeUrlSchema = z.string().url()
.refine(
(url) => URL.canParse(url),
{message: "URL must be parseable"}
).refine(
(url) => {
const u = url.trim().toLowerCase();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could just use URL.protocol, which will do the lowercase for us (as per https://url.spec.whatwg.org/#scheme-start-state )

const u = new URL(url);
return (u !== 'javascript:') && (u !== 'data:') && (u !== 'vbscript:');

Or... wondering if we should just allow https: and http:, otherwise futurescript: will be at risk when it comes out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, yea checking protocol sounds better.

for allow vs. deny, some discussion in #841 (comment) -- there are legitimate app url schemes that I think we need to allow, e.g. for mobile.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

had to get a little funky with Zod to make the parseable URL check to fail. (using superRefine). Looks like zod 4.0 makes this a little simpler if we upgrade.

return !(
u.startsWith('javascript:') ||
u.startsWith('data:') ||
u.startsWith('vbscript:')
);
},
{ message: "URL cannot use javascript:, data:, or vbscript: scheme" }
);


/**
* RFC 9728 OAuth Protected Resource Metadata
*/
export const OAuthProtectedResourceMetadataSchema = z
.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()).optional(),
authorization_servers: z.array(SafeUrlSchema).optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
Expand All @@ -28,9 +48,9 @@ export const OAuthProtectedResourceMetadataSchema = z
export const OAuthMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string().optional(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
Expand All @@ -39,8 +59,8 @@ export const OAuthMetadataSchema = z
token_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
service_documentation: z.string().optional(),
revocation_endpoint: z.string().optional(),
service_documentation: SafeUrlSchema.optional(),
revocation_endpoint: SafeUrlSchema.optional(),
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
revocation_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
Expand All @@ -63,11 +83,11 @@ export const OAuthMetadataSchema = z
export const OpenIdProviderMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
userinfo_endpoint: z.string().optional(),
jwks_uri: z.string(),
registration_endpoint: z.string().optional(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
userinfo_endpoint: SafeUrlSchema.optional(),
jwks_uri: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
Expand Down Expand Up @@ -101,8 +121,8 @@ export const OpenIdProviderMetadataSchema = z
request_parameter_supported: z.boolean().optional(),
request_uri_parameter_supported: z.boolean().optional(),
require_request_uri_registration: z.boolean().optional(),
op_policy_uri: z.string().optional(),
op_tos_uri: z.string().optional(),
op_policy_uri: SafeUrlSchema.optional(),
op_tos_uri: SafeUrlSchema.optional(),
})
.passthrough();

Expand Down Expand Up @@ -146,18 +166,18 @@ export const OAuthErrorResponseSchema = z
* RFC 7591 OAuth 2.0 Dynamic Client Registration metadata
*/
export const OAuthClientMetadataSchema = z.object({
redirect_uris: z.array(z.string()).refine((uris) => uris.every((uri) => URL.canParse(uri)), { message: "redirect_uris must contain valid URLs" }),
redirect_uris: z.array(SafeUrlSchema),
token_endpoint_auth_method: z.string().optional(),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
client_uri: SafeUrlSchema.optional(),
logo_uri: SafeUrlSchema.optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
tos_uri: SafeUrlSchema.optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks_uri: SafeUrlSchema.optional(),
jwks: z.any().optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
Expand Down