~bigbes/lethe

ref: be6e43e7b0b449dcbb6d597f5fe243ba5235cf75 lethe/web/src/lib/auth.ts -rw-r--r-- 4.9 KiB
be6e43e7 — Eugene Blikh oidcstub: percent-encode authorize redirect query params (PC1) a month 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
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
// ── Base64url helpers ─────────────────────────────────────────────────────────

/**
 * RFC 4648 §5 base64url-no-pad encoding.
 * Internal helper; exported for tests.
 */
export function b64url(buf: ArrayBuffer | Uint8Array): string {
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
  let binary = ''
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}

// ── PKCE ─────────────────────────────────────────────────────────────────────

/**
 * Generate a PKCE verifier+challenge pair.
 * Verifier: 43-byte random buffer → 64 base64url chars (satisfies RFC 7636 §4.1).
 * Challenge: SHA-256(verifier) in base64url-no-pad (AS1 — requires crypto.subtle).
 */
export async function generatePKCEPair(): Promise<{ verifier: string; challenge: string }> {
  const raw = crypto.getRandomValues(new Uint8Array(43))
  const verifier = b64url(raw)
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  const challenge = b64url(hash)
  return { verifier, challenge }
}

// ── State ─────────────────────────────────────────────────────────────────────

/**
 * Generate an opaque CSRF state value.
 * 16 random bytes → 22 base64url chars (no padding).
 */
export function generateState(): string {
  const raw = crypto.getRandomValues(new Uint8Array(16))
  return b64url(raw)
}

// ── Callback params ───────────────────────────────────────────────────────────

type CallbackSuccess = { code: string; state: string }
type CallbackError = { error: string; errorDescription?: string }
type CallbackParams = CallbackSuccess | CallbackError

/**
 * Parse the query string from an OAuth2/OIDC authorization callback.
 *
 * Returns a discriminated union:
 * - `{ code, state }` on success
 * - `{ error, errorDescription? }` when the OP reports an error
 * - `{ error: 'missing_params' }` when neither `code` nor `error` is present
 */
export function parseCallbackParams(search: string): CallbackParams {
  const params = new URLSearchParams(search)

  const code = params.get('code')
  const state = params.get('state')
  if (code !== null && state !== null) {
    return { code, state }
  }

  const error = params.get('error')
  if (error !== null) {
    const result: CallbackError = { error }
    const description = params.get('error_description')
    if (description !== null) {
      result.errorDescription = description
    }
    return result
  }

  return { error: 'missing_params' }
}

// ── TokenStore ────────────────────────────────────────────────────────────────

export interface TokenStore {
  get(): string | null
  set(token: string | null): void
  subscribe(listener: (token: string | null) => void): () => void
}

function createTokenStore(): TokenStore {
  let current: string | null = null
  const listeners: Array<(token: string | null) => void> = []

  return {
    get(): string | null {
      return current
    },
    set(token: string | null): void {
      current = token
      // Notify all registered listeners. Slice to avoid mutation issues during iteration.
      listeners.slice().forEach((fn) => fn(token))
    },
    subscribe(listener: (token: string | null) => void): () => void {
      listeners.push(listener)
      return () => {
        const idx = listeners.indexOf(listener)
        if (idx !== -1) {
          listeners.splice(idx, 1)
        }
      }
    },
  }
}

/**
 * Module-level singleton token store.
 * In-memory only — never touches localStorage (IV4).
 */
export const tokenStore: TokenStore = createTokenStore()

// ── Callback failure counter ──────────────────────────────────────────────────

const WINDOW_MS = 300_000 // 5 minutes

/**
 * Pure helper for the callback page's anti-loop guard.
 *
 * Counts how many entries in `log` (timestamps in ms) fall within the past
 * 5 minutes relative to `now`. Returns `blocked: true` when count > 3.
 * Does not mutate `log`.
 */
export function countCallbackFailures(
  now: number,
  log: number[],
): { count: number; blocked: boolean } {
  const cutoff = now - WINDOW_MS
  const count = log.filter((ts) => ts > cutoff).length
  return { count, blocked: count > 3 }
}