| 1 | import { oauthStore, type OAuthStore } from './oauth-store.js';
|
| 2 | import { guardRequest } from './request-security.js';
|
| 3 | import { assertProductionConfiguration } from './production-config.js';
|
| 4 |
|
| 5 | assertProductionConfiguration('auth');
|
| 6 |
|
| 7 | const maximumRegistrationBytes = 32_768;
|
| 8 |
|
| 9 | function validRedirectUri(value: unknown): value is string {
|
| 10 | if (typeof value !== 'string' || value.length > 2048) return false;
|
| 11 | try {
|
| 12 | const url = new URL(value);
|
| 13 | if (url.hash || url.username || url.password) return false;
|
| 14 | if (url.protocol === 'https:') return true;
|
| 15 | if (url.protocol === 'http:') return ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
|
| 16 | return !['javascript:', 'data:', 'file:', 'vbscript:'].includes(url.protocol);
|
| 17 | } catch {
|
| 18 | return false;
|
| 19 | }
|
| 20 | }
|
| 21 |
|
| 22 | export async function register(request: Request, store: OAuthStore): Promise<Response> {
|
| 23 | const body = await request.text();
|
| 24 | if (new TextEncoder().encode(body).byteLength > maximumRegistrationBytes)
|
| 25 | return Response.json({ error: 'invalid_client_metadata' }, { status: 413 });
|
| 26 |
|
| 27 | let metadata: Record<string, unknown>;
|
| 28 | try {
|
| 29 | const parsed = JSON.parse(body) as unknown;
|
| 30 | if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
|
| 31 | metadata = parsed as Record<string, unknown>;
|
| 32 | } catch {
|
| 33 | return Response.json({ error: 'invalid_client_metadata' }, { status: 400 });
|
| 34 | }
|
| 35 |
|
| 36 | const redirectUris = metadata.redirect_uris;
|
| 37 | if (
|
| 38 | !Array.isArray(redirectUris) ||
|
| 39 | redirectUris.length === 0 ||
|
| 40 | redirectUris.length > 10 ||
|
| 41 | !redirectUris.every(validRedirectUri)
|
| 42 | )
|
| 43 | return Response.json(
|
| 44 | { error: 'invalid_redirect_uri', error_description: 'Valid redirect_uris are required' },
|
| 45 | { status: 400 },
|
| 46 | );
|
| 47 | if (
|
| 48 | metadata.token_endpoint_auth_method !== undefined &&
|
| 49 | metadata.token_endpoint_auth_method !== 'none'
|
| 50 | )
|
| 51 | return Response.json(
|
| 52 | {
|
| 53 | error: 'invalid_client_metadata',
|
| 54 | error_description: 'Only public PKCE clients are supported',
|
| 55 | },
|
| 56 | { status: 400 },
|
| 57 | );
|
| 58 |
|
| 59 | const client = await store.registerClient({
|
| 60 | ...metadata,
|
| 61 | redirect_uris: redirectUris,
|
| 62 | token_endpoint_auth_method: 'none',
|
| 63 | grant_types: ['authorization_code', 'refresh_token'],
|
| 64 | response_types: ['code'],
|
| 65 | });
|
| 66 | console.info('[oauth.dcr] OpenClasp client registered', {
|
| 67 | clientId: client.clientId,
|
| 68 | redirectUriCount: client.redirectUris.length,
|
| 69 | });
|
| 70 | return Response.json(
|
| 71 | {
|
| 72 | ...metadata,
|
| 73 | client_id: client.clientId,
|
| 74 | client_id_issued_at: Math.floor(Date.now() / 1000),
|
| 75 | redirect_uris: client.redirectUris,
|
| 76 | token_endpoint_auth_method: 'none',
|
| 77 | grant_types: ['authorization_code', 'refresh_token'],
|
| 78 | response_types: ['code'],
|
| 79 | },
|
| 80 | {
|
| 81 | status: 201,
|
| 82 | headers: {
|
| 83 | 'access-control-allow-origin': '*',
|
| 84 | 'cache-control': 'no-store',
|
| 85 | },
|
| 86 | },
|
| 87 | );
|
| 88 | }
|
| 89 |
|
| 90 | export async function POST(request: Request): Promise<Response> {
|
| 91 | const rejected = await guardRequest(request, 'oauth-register', {
|
| 92 | limit: 20,
|
| 93 | maximumBytes: maximumRegistrationBytes,
|
| 94 | });
|
| 95 | if (rejected) return rejected;
|
| 96 | try {
|
| 97 | return await register(request, oauthStore());
|
| 98 | } catch {
|
| 99 | return Response.json({ error: 'temporarily_unavailable' }, { status: 503 });
|
| 100 | }
|
| 101 | }
|
| 102 |
|
| 103 | export function OPTIONS(): Response {
|
| 104 | return new Response(null, {
|
| 105 | status: 204,
|
| 106 | headers: {
|
| 107 | 'access-control-allow-headers': 'content-type',
|
| 108 | 'access-control-allow-methods': 'POST, OPTIONS',
|
| 109 | 'access-control-allow-origin': '*',
|
| 110 | 'access-control-max-age': '86400',
|
| 111 | },
|
| 112 | });
|
| 113 | }
|