conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }