auth.ts (6694B)
1 // web/src/util/auth.ts - the authorization code flow, against either provider 2 // 3 // The conductor answers discovery for both its own login page and a 4 // configured provider, so there is one code path here rather than two. 5 6 const STORE = 'conductor.refresh'; 7 const PENDING = 'conductor.pending'; 8 const IDENTITY = 'conductor.identity'; 9 10 export type Discovery = { 11 issuer: string; 12 authorization_endpoint: string; 13 token_endpoint: string; 14 end_session_endpoint?: string; 15 revocation_endpoint?: string; 16 client_id: string; 17 scopes?: string; 18 }; 19 20 export const session = { 21 user: null as { id: string; username: string; role: string } | null, 22 }; 23 24 let discovery: Discovery | null = null; 25 let accessToken: string | null = null; 26 let expiresAt = 0; 27 28 function encode(bytes: Uint8Array): string { 29 let s = ''; 30 for (const b of bytes) s += String.fromCharCode(b); 31 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 32 } 33 34 function random(): string { 35 return encode(crypto.getRandomValues(new Uint8Array(32))); 36 } 37 38 async function challengeFor(verifier: string): Promise<string> { 39 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); 40 return encode(new Uint8Array(digest)); 41 } 42 43 // The fragment is the router's, so the code comes back on the query instead. 44 function redirectUri(): string { 45 return location.origin + location.pathname; 46 } 47 48 export function refreshToken(): string | null { 49 return localStorage.getItem(STORE); 50 } 51 52 export function signedIn(): boolean { 53 return refreshToken() !== null; 54 } 55 56 export async function load(): Promise<Discovery> { 57 if (discovery) return discovery; 58 const res = await fetch('.well-known/openid-configuration'); 59 if (!res.ok) throw new Error(`discovery failed: ${res.status}`); 60 discovery = await res.json(); 61 return discovery as Discovery; 62 } 63 64 export async function signIn(): Promise<void> { 65 const doc = await load(); 66 const verifier = random(); 67 const state = random(); 68 sessionStorage.setItem(PENDING, JSON.stringify({ verifier, state, at: location.hash })); 69 70 const url = new URL(doc.authorization_endpoint); 71 url.searchParams.set('response_type', 'code'); 72 url.searchParams.set('client_id', doc.client_id); 73 url.searchParams.set('redirect_uri', redirectUri()); 74 url.searchParams.set('scope', doc.scopes || 'openid'); 75 url.searchParams.set('state', state); 76 url.searchParams.set('code_challenge', await challengeFor(verifier)); 77 url.searchParams.set('code_challenge_method', 'S256'); 78 location.assign(url.toString()); 79 } 80 81 // Returns where the browser was headed before it was sent to sign in. 82 export async function completeSignIn(): Promise<string | null> { 83 const query = new URLSearchParams(location.search); 84 const code = query.get('code'); 85 const error = query.get('error'); 86 if (!code && !error) return null; 87 88 const raw = sessionStorage.getItem(PENDING); 89 sessionStorage.removeItem(PENDING); 90 history.replaceState(null, '', redirectUri() + location.hash); 91 92 if (error) throw new Error(query.get('error_description') || error); 93 if (!raw) throw new Error('this sign in did not start here'); 94 95 const pending = JSON.parse(raw); 96 if (pending.state !== query.get('state')) throw new Error('this sign in did not start here'); 97 98 const doc = await load(); 99 await exchange(doc, { 100 grant_type: 'authorization_code', 101 code: code as string, 102 redirect_uri: redirectUri(), 103 code_verifier: pending.verifier, 104 client_id: doc.client_id, 105 }); 106 return pending.at || null; 107 } 108 109 export class GrantError extends Error { 110 status: number; 111 112 constructor(status: number, message: string) { 113 super(message); 114 this.status = status; 115 } 116 } 117 118 async function exchange(doc: Discovery, body: Record<string, string>): Promise<void> { 119 const res = await fetch(doc.token_endpoint, { 120 method: 'POST', 121 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 122 body: new URLSearchParams(body).toString(), 123 }); 124 if (!res.ok) { 125 let detail = `${res.status}`; 126 try { 127 const payload = await res.json(); 128 detail = payload.error_description || payload.error || detail; 129 } catch { /* the status is all there is */ } 130 throw new GrantError(res.status, detail); 131 } 132 133 const grant = await res.json(); 134 accessToken = grant.access_token; 135 expiresAt = Date.now() + (grant.expires_in ?? 300) * 1000; 136 if (grant.refresh_token) localStorage.setItem(STORE, grant.refresh_token); 137 if (grant.id_token) localStorage.setItem(IDENTITY, grant.id_token); 138 } 139 140 let refreshing: Promise<void> | null = null; 141 142 // Concurrent requests finding an expired token must refresh once between 143 // them, not once each. 144 export async function refresh(): Promise<void> { 145 if (refreshing) return refreshing; 146 const token = refreshToken(); 147 if (!token) throw new Error('not signed in'); 148 149 const doc = await load(); 150 refreshing = exchange(doc, { 151 grant_type: 'refresh_token', 152 refresh_token: token, 153 client_id: doc.client_id, 154 }).catch((e) => { 155 // A refused grant is final, unlike a provider that is merely unwell. 156 if (e instanceof GrantError && e.status < 500) forget(); 157 throw e; 158 }).finally(() => { refreshing = null; }); 159 160 return refreshing; 161 } 162 163 // Renewed a little early, so a request is not sent with a token that dies 164 // on the way. A credential refused along the way leaves the caller 165 // anonymous rather than stuck. 166 export async function bearer(): Promise<string | null> { 167 if (!refreshToken()) return null; 168 if (!accessToken || Date.now() > expiresAt - 10_000) { 169 try { 170 await refresh(); 171 } catch (e) { 172 if (refreshToken()) throw e; 173 return null; 174 } 175 } 176 return accessToken; 177 } 178 179 export function forget(): void { 180 accessToken = null; 181 expiresAt = 0; 182 session.user = null; 183 localStorage.removeItem(STORE); 184 localStorage.removeItem(IDENTITY); 185 } 186 187 export async function signOut(): Promise<void> { 188 const token = refreshToken(); 189 const identity = localStorage.getItem(IDENTITY); 190 const doc = discovery; 191 192 if (doc?.revocation_endpoint && token) { 193 await fetch(doc.revocation_endpoint, { 194 method: 'POST', 195 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 196 body: new URLSearchParams({ token, client_id: doc.client_id }).toString(), 197 }).catch(() => { /* the local session goes either way */ }); 198 } 199 200 forget(); 201 202 if (doc?.end_session_endpoint) { 203 const url = new URL(doc.end_session_endpoint); 204 url.searchParams.set('post_logout_redirect_uri', redirectUri()); 205 url.searchParams.set('client_id', doc.client_id); 206 if (identity) url.searchParams.set('id_token_hint', identity); 207 location.assign(url.toString()); 208 return; 209 } 210 location.assign(redirectUri()); 211 }