-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.ts
More file actions
156 lines (139 loc) · 5.99 KB
/
Copy pathauth.ts
File metadata and controls
156 lines (139 loc) · 5.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/**
* Cognito Hosted UI, authorization-code + PKCE flow (RFC 7636), against ADR-0022's app client:
* `generateSecret: false`, so running this entirely in the browser is the correct place for it
* — there is no secret to protect on a server this slice doesn't have anyway.
*
* PKCE state (the code verifier, the CSRF state token) lives in `sessionStorage`, not
* `localStorage`: it's only needed for one in-flight redirect round trip, never across tabs or
* after sign-in completes.
*/
export interface AuthConfig {
readonly domain: string; // e.g. "openjobradar-dev.auth.us-east-1.amazoncognito.com" — no scheme
readonly clientId: string;
readonly redirectUri: string;
readonly logoutUri: string;
readonly scopes?: readonly string[];
}
export interface TokenResponse {
readonly access_token: string;
readonly id_token: string;
readonly refresh_token?: string;
readonly token_type: string;
readonly expires_in: number;
}
const DEFAULT_SCOPES: readonly string[] = ['openid', 'email', 'profile'];
const VERIFIER_KEY = 'ojr.pkce.verifier';
const STATE_KEY = 'ojr.pkce.state';
const VERIFIER_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
function base64UrlEncode(bytes: ArrayBuffer | Uint8Array): string {
const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let binary = '';
for (const byte of array) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** RFC 7636 §4.1: 43-128 characters from `[A-Za-z0-9-._~]`. */
export function generateCodeVerifier(length = 64): string {
if (length < 43 || length > 128) {
throw new RangeError('code_verifier length must be between 43 and 128 (RFC 7636 §4.1)');
}
const random = new Uint8Array(length);
crypto.getRandomValues(random);
let verifier = '';
for (const byte of random) verifier += VERIFIER_ALPHABET[byte % VERIFIER_ALPHABET.length];
return verifier;
}
/** RFC 7636 §4.2: `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`. */
export async function generateCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64UrlEncode(digest);
}
/** Not part of PKCE itself — a separate CSRF token Cognito's Hosted UI echoes back unmodified. */
export function generateState(length = 32): string {
const random = new Uint8Array(length);
crypto.getRandomValues(random);
return base64UrlEncode(random);
}
export function buildAuthorizeUrl(config: AuthConfig, codeChallenge: string, state: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: (config.scopes ?? DEFAULT_SCOPES).join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return `https://${config.domain}/oauth2/authorize?${params.toString()}`;
}
export function buildLogoutUrl(config: AuthConfig): string {
const params = new URLSearchParams({ client_id: config.clientId, logout_uri: config.logoutUri });
return `https://${config.domain}/logout?${params.toString()}`;
}
export class TokenExchangeError extends Error {}
export async function exchangeCodeForTokens(
config: AuthConfig,
code: string,
codeVerifier: string,
fetchImpl: typeof fetch = fetch,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
code,
redirect_uri: config.redirectUri,
code_verifier: codeVerifier,
});
const response = await fetchImpl(`https://${config.domain}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new TokenExchangeError(`token exchange failed (${response.status}): ${text.slice(0, 300)}`);
}
const json = (await response.json()) as Partial<TokenResponse>;
if (typeof json.access_token !== 'string' || typeof json.id_token !== 'string') {
throw new TokenExchangeError('token response is missing access_token/id_token');
}
return json as TokenResponse;
}
// --- Session-flow orchestration (sessionStorage-backed; browser-only) --------------------
export class CallbackError extends Error {}
/** Call when the user clicks "sign in" — stashes PKCE state and returns the URL to redirect to. */
export async function beginSignIn(config: AuthConfig): Promise<string> {
const verifier = generateCodeVerifier();
const state = generateState();
sessionStorage.setItem(VERIFIER_KEY, verifier);
sessionStorage.setItem(STATE_KEY, state);
const challenge = await generateCodeChallenge(verifier);
return buildAuthorizeUrl(config, challenge, state);
}
/** Call on the redirect-back page with `window.location.href`. Clears PKCE state either way —
* a failed or completed attempt should never be retried with stale state. */
export async function completeSignIn(
config: AuthConfig,
callbackUrl: string,
fetchImpl: typeof fetch = fetch,
): Promise<TokenResponse> {
const params = new URL(callbackUrl).searchParams;
const expectedState = sessionStorage.getItem(STATE_KEY);
const verifier = sessionStorage.getItem(VERIFIER_KEY);
sessionStorage.removeItem(VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
const error = params.get('error');
if (error) {
throw new CallbackError(`authorization failed: ${error} ${params.get('error_description') ?? ''}`.trim());
}
const code = params.get('code');
if (!code) {
throw new CallbackError('callback URL carries no authorization code');
}
if (!expectedState || params.get('state') !== expectedState) {
throw new CallbackError('state mismatch — possible CSRF, or a stale/replayed callback');
}
if (!verifier) {
throw new CallbackError('no code_verifier in session — sign-in was not started in this browser session');
}
return exchangeCodeForTokens(config, code, verifier, fetchImpl);
}