~james/core

core/status/src/access.ts -rw-r--r-- 2.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
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
export interface AccessEnv {
  ACCESS_TEAM_DOMAIN?: string;
  ACCESS_AUD?: string;
}

export interface TeamCertKey {
  kty: string;
  kid?: string;
  use?: string;
  alg?: string;
  n?: string;
  e?: string;
}

interface AccessClaims {
  iss: string;
  aud: string | string[];
  exp: number;
  iat: number;
  email?: string;
  sub?: string;
}

const CLOCK_SKEW_MS = 60 * 1000;

const b64urlBytes = (part: string): Uint8Array => {
  const padded = part
    .replace(/-/g, '+')
    .replace(/_/g, '/')
    .padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
  const binary = atob(padded);
  return Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
};

export class VerifiedAccessIdentity {
  readonly #claims: Readonly<{ email: string }>;

  private constructor(claims: { email: string }) {
    this.#claims = Object.freeze({ email: claims.email });
  }

  get email(): string {
    return this.#claims.email;
  }

  static async verify(token: string | null, env: AccessEnv): Promise<VerifiedAccessIdentity | null> {
    if (!token || !env.ACCESS_TEAM_DOMAIN || !env.ACCESS_AUD) return null;
    const [head, payload, signature] = token.split('.');
    if (!head || !payload || !signature) return null;

    try {
      const header = JSON.parse(new TextDecoder().decode(b64urlBytes(head))) as {
        alg?: string;
        kid?: string;
      };
      if (header.alg !== 'RS256' || !header.kid) return null;

      const certsResponse = await fetch(`https://${env.ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`);
      if (!certsResponse.ok) throw new Error(`certs endpoint returned ${certsResponse.status}`);
      const certs = (await certsResponse.json()) as { keys: TeamCertKey[] };
      const key = certs.keys.find((k) => k.kid === header.kid);
      if (!key) return null;

      const cryptoKey = await crypto.subtle.importKey(
        'jwk',
        key,
        { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
        false,
        ['verify'],
      );
      const signed = new TextEncoder().encode(`${head}.${payload}`);
      const valid = await crypto.subtle.verify(
        'RSASSA-PKCS1-v1_5',
        cryptoKey,
        b64urlBytes(signature) as unknown as ArrayBuffer,
        signed as unknown as ArrayBuffer,
      );
      if (!valid) return null;

      const claims = JSON.parse(new TextDecoder().decode(b64urlBytes(payload))) as AccessClaims;
      const now = Date.now();
      if (claims.iss !== `https://${env.ACCESS_TEAM_DOMAIN}`) return null;
      const audOk = Array.isArray(claims.aud)
        ? claims.aud.includes(env.ACCESS_AUD)
        : claims.aud === env.ACCESS_AUD;
      if (!audOk) return null;
      if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') return null;
      if (claims.exp * 1000 <= now - CLOCK_SKEW_MS) return null;
      if (claims.iat * 1000 > now + CLOCK_SKEW_MS) return null;

      return new VerifiedAccessIdentity({
        email: claims.email ?? claims.sub ?? 'access user',
      });
    } catch {
      return null;
    }
  }
}