~bigbes/lethe

ref: ef738a28b65454441af0c98c23a389cfa24fb22c lethe/web/src/lib/auth.ts -rw-r--r-- 4.9 KiB
ef738a28 — Eugene Blikh collector: align ingest sender with server response 24 days 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 }
}