@everystack/auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @everystack/auth/jwt — runtime-agnostic HS256 JWT, zero runtime deps.
3
+ *
4
+ * Design contract (see __tests__/jwt/fixtures/vectors.ts for binding details):
5
+ * - Algorithm: HS256, fixed. Verify rejects every other `alg` including 'none'.
6
+ * - Header: literal `{"alg":"HS256","typ":"JWT"}` — no variation.
7
+ * - base64url: RFC 4648 §5, no padding.
8
+ * - exp: integer epoch seconds, REQUIRED on verify (with optional clock skew).
9
+ * - The library does not own the claim shape. Pass a TClaims generic and
10
+ * getSubject/getRole accessors to support legacy payloads.
11
+ */
12
+
13
+ import type { HmacFn } from './hmac';
14
+
15
+ const HEADER_JSON = '{"alg":"HS256","typ":"JWT"}';
16
+ const HEADER_B64 = base64urlEncode(textEncode(HEADER_JSON));
17
+
18
+ export interface JwtConfig<TClaims extends Record<string, unknown> = Record<string, unknown>> {
19
+ /** HMAC signing key. String is UTF-8 encoded. */
20
+ secret: string | Uint8Array;
21
+ /** HMAC adapter. Required — pick hmacNode or hmacWeb. */
22
+ hmac: HmacFn;
23
+ /** Token lifetime in seconds. Default 3600. Used when sign() auto-fills `exp`. */
24
+ ttlSec?: number;
25
+ /** Allowed clock skew on verify, seconds. Default 30. */
26
+ clockSkewSec?: number;
27
+ /** Whether sign() should auto-add a `jti` claim. Default true. */
28
+ includeJti?: boolean;
29
+ /** Claim name used to read subject. Default 'sub'. */
30
+ getSubject?: (claims: TClaims) => string;
31
+ /** Claim name used to read role. Default reads `claims.role`. */
32
+ getRole?: (claims: TClaims) => string;
33
+ /** Claim used as expiry (epoch seconds). Default reads `claims.exp`. */
34
+ getExpiry?: (claims: TClaims) => number | undefined;
35
+ /**
36
+ * If true, sign() does not reorder/inject keys — the JSON.stringify of
37
+ * the claims object is used verbatim. Required for byte-identical tokens
38
+ * across implementations.
39
+ */
40
+ canonical?: boolean;
41
+ }
42
+
43
+ export interface SignOptions {
44
+ /**
45
+ * Skip auto-filling iat/exp/jti. The claims object is signed as-is.
46
+ * Used when the caller (or SQL) controls timestamps.
47
+ */
48
+ skipTimestamps?: boolean;
49
+ }
50
+
51
+ export interface Jwt<TClaims extends Record<string, unknown>> {
52
+ sign(claims: TClaims, opts?: SignOptions): Promise<string>;
53
+ verify(token: string): Promise<TClaims | null>;
54
+ /** Decode the payload without verifying. Useful for debugging only. */
55
+ decode(token: string): TClaims | null;
56
+ }
57
+
58
+ export function createJwt<
59
+ TClaims extends Record<string, unknown> = Record<string, unknown>
60
+ >(config: JwtConfig<TClaims>): Jwt<TClaims> {
61
+ const secretBytes =
62
+ typeof config.secret === 'string' ? textEncode(config.secret) : config.secret;
63
+ const hmac = config.hmac;
64
+ const ttlSec = config.ttlSec ?? 3600;
65
+ const clockSkewSec = config.clockSkewSec ?? 30;
66
+ const includeJti = config.includeJti ?? true;
67
+ const getExpiry = config.getExpiry ?? ((c) => c.exp as number | undefined);
68
+
69
+ async function sign(claims: TClaims, opts: SignOptions = {}): Promise<string> {
70
+ let payload: Record<string, unknown> = claims;
71
+ if (!opts.skipTimestamps) {
72
+ const now = Math.floor(Date.now() / 1000);
73
+ const additions: Record<string, unknown> = {};
74
+ if (includeJti && payload.jti === undefined) additions.jti = randomJti();
75
+ if (payload.iat === undefined) additions.iat = now;
76
+ if (payload.exp === undefined) additions.exp = now + ttlSec;
77
+ payload = { ...additions, ...claims };
78
+ }
79
+ const payloadJson = JSON.stringify(payload);
80
+ const payloadB64 = base64urlEncode(textEncode(payloadJson));
81
+ const signingInput = `${HEADER_B64}.${payloadB64}`;
82
+ const sig = await hmac(secretBytes, textEncode(signingInput));
83
+ return `${signingInput}.${base64urlEncode(sig)}`;
84
+ }
85
+
86
+ async function verify(token: string): Promise<TClaims | null> {
87
+ if (typeof token !== 'string' || token.length === 0) return null;
88
+ const parts = token.split('.');
89
+ if (parts.length !== 3) return null;
90
+ const [headerB64, payloadB64, sigB64] = parts;
91
+
92
+ // Header must decode to exactly the canonical HS256 header. This rejects
93
+ // alg=none, alg=RS256 confusion, and any other algorithm variation.
94
+ let header: { alg?: string; typ?: string };
95
+ try {
96
+ header = JSON.parse(textDecode(base64urlDecode(headerB64)));
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (header.alg !== 'HS256' || header.typ !== 'JWT') return null;
101
+
102
+ const expected = await hmac(secretBytes, textEncode(`${headerB64}.${payloadB64}`));
103
+ let actual: Uint8Array;
104
+ try {
105
+ actual = base64urlDecode(sigB64);
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (!constantTimeEqual(expected, actual)) return null;
110
+
111
+ let claims: TClaims;
112
+ try {
113
+ claims = JSON.parse(textDecode(base64urlDecode(payloadB64))) as TClaims;
114
+ } catch {
115
+ return null;
116
+ }
117
+
118
+ const exp = getExpiry(claims);
119
+ if (typeof exp !== 'number') return null;
120
+ const now = Math.floor(Date.now() / 1000);
121
+ if (exp + clockSkewSec < now) return null;
122
+
123
+ return claims;
124
+ }
125
+
126
+ function decode(token: string): TClaims | null {
127
+ const parts = token.split('.');
128
+ if (parts.length !== 3) return null;
129
+ try {
130
+ return JSON.parse(textDecode(base64urlDecode(parts[1]))) as TClaims;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ return { sign, verify, decode };
137
+ }
138
+
139
+ // ─── helpers ────────────────────────────────────────────────────────────────
140
+
141
+ function textEncode(s: string): Uint8Array {
142
+ return new TextEncoder().encode(s);
143
+ }
144
+ function textDecode(b: Uint8Array): string {
145
+ return new TextDecoder().decode(b);
146
+ }
147
+
148
+ export function base64urlEncode(bytes: Uint8Array): string {
149
+ let s = '';
150
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
151
+ // btoa is available in node 16+ and all browsers/workers.
152
+ const b64 = typeof btoa === 'function' ? btoa(s) : Buffer.from(bytes).toString('base64');
153
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
154
+ }
155
+
156
+ export function base64urlDecode(s: string): Uint8Array {
157
+ const std = s.replace(/-/g, '+').replace(/_/g, '/');
158
+ const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
159
+ const bin = typeof atob === 'function' ? atob(std + pad) : Buffer.from(std + pad, 'base64').toString('binary');
160
+ const out = new Uint8Array(bin.length);
161
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
162
+ return out;
163
+ }
164
+
165
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
166
+ if (a.length !== b.length) return false;
167
+ let diff = 0;
168
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
169
+ return diff === 0;
170
+ }
171
+
172
+ function randomJti(): string {
173
+ // RFC 4122 v4 UUID via crypto.getRandomValues — no deps.
174
+ const bytes = new Uint8Array(16);
175
+ crypto.getRandomValues(bytes);
176
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
177
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
178
+ const h = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
179
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
180
+ }
181
+
182
+ export type { HmacFn } from './hmac';
@@ -0,0 +1,177 @@
1
+ -- =============================================================================
2
+ -- @everystack/auth — JWT (HS256) — SQL twin
3
+ --
4
+ -- Pure pgcrypto. No pgsodium, no extensions beyond pgcrypto.
5
+ --
6
+ -- Two entry points:
7
+ -- auth.jwt_sign_raw(payload_json text, secret text) → text
8
+ -- Signs the *exact* payload string verbatim. Use this when you need
9
+ -- byte-identical output to a known canonical JSON (cross-impl conformance).
10
+ --
11
+ -- auth.jwt_sign(claims jsonb, secret text) → text
12
+ -- Convenience wrapper that serializes claims via claims::text.
13
+ -- Note: jsonb sorts keys alphabetically — tokens from this function
14
+ -- are valid but not byte-identical to JS-issued tokens. Cross-verification
15
+ -- still works (verify only checks the signature over the bytes received).
16
+ --
17
+ -- auth.jwt_verify(token text, secret text) → jsonb
18
+ -- Verifies HS256 signature, header (alg=HS256, typ=JWT), and exp.
19
+ -- Returns claims jsonb on success, NULL on any failure.
20
+ --
21
+ -- All functions are pure (no session writes). Compose with your auth schema
22
+ -- to build authenticate() / authenticate_request() flows.
23
+ --
24
+ -- Requires: CREATE EXTENSION IF NOT EXISTS pgcrypto;
25
+ -- =============================================================================
26
+
27
+ CREATE SCHEMA IF NOT EXISTS auth;
28
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
29
+
30
+ -- -----------------------------------------------------------------------------
31
+ -- base64url helpers (RFC 4648 §5, no padding)
32
+ -- -----------------------------------------------------------------------------
33
+ CREATE OR REPLACE FUNCTION auth.base64url_encode(data bytea)
34
+ RETURNS text
35
+ LANGUAGE sql IMMUTABLE STRICT
36
+ AS $$
37
+ SELECT rtrim(
38
+ replace(replace(replace(encode(data, 'base64'), E'\n', ''), '+', '-'), '/', '_'),
39
+ '='
40
+ );
41
+ $$;
42
+
43
+ CREATE OR REPLACE FUNCTION auth.base64url_decode(data text)
44
+ RETURNS bytea
45
+ LANGUAGE plpgsql IMMUTABLE STRICT
46
+ AS $$
47
+ DECLARE
48
+ v_std text;
49
+ v_mod int;
50
+ BEGIN
51
+ v_std := replace(replace(data, '-', '+'), '_', '/');
52
+ v_mod := length(v_std) % 4;
53
+ IF v_mod = 2 THEN v_std := v_std || '==';
54
+ ELSIF v_mod = 3 THEN v_std := v_std || '=';
55
+ END IF;
56
+ RETURN decode(v_std, 'base64');
57
+ END;
58
+ $$;
59
+
60
+ -- -----------------------------------------------------------------------------
61
+ -- auth.jwt_sign_raw(payload_json text, secret text) → text
62
+ --
63
+ -- Signs payload_json verbatim. The caller is responsible for canonicalization.
64
+ -- This is the byte-identical primitive used by the conformance suite.
65
+ -- -----------------------------------------------------------------------------
66
+ CREATE OR REPLACE FUNCTION auth.jwt_sign_raw(p_payload_json text, p_secret text)
67
+ RETURNS text
68
+ LANGUAGE plpgsql
69
+ IMMUTABLE STRICT
70
+ SET search_path = auth, public, pg_temp
71
+ AS $$
72
+ DECLARE
73
+ v_header_b64 text;
74
+ v_payload_b64 text;
75
+ v_signing_input text;
76
+ v_sig_b64 text;
77
+ BEGIN
78
+ v_header_b64 := auth.base64url_encode(convert_to('{"alg":"HS256","typ":"JWT"}', 'UTF8'));
79
+ v_payload_b64 := auth.base64url_encode(convert_to(p_payload_json, 'UTF8'));
80
+ v_signing_input := v_header_b64 || '.' || v_payload_b64;
81
+ v_sig_b64 := auth.base64url_encode(
82
+ hmac(convert_to(v_signing_input, 'UTF8'), convert_to(p_secret, 'UTF8'), 'sha256')
83
+ );
84
+ RETURN v_signing_input || '.' || v_sig_b64;
85
+ END;
86
+ $$;
87
+
88
+ COMMENT ON FUNCTION auth.jwt_sign_raw(text, text) IS
89
+ 'Sign a JWT HS256 token from a verbatim payload JSON string. Use for canonical/byte-identical output.';
90
+
91
+ -- -----------------------------------------------------------------------------
92
+ -- auth.jwt_sign(claims jsonb, secret text) → text
93
+ --
94
+ -- Convenience wrapper. Note jsonb key ordering — see header comments.
95
+ -- -----------------------------------------------------------------------------
96
+ CREATE OR REPLACE FUNCTION auth.jwt_sign(p_claims jsonb, p_secret text)
97
+ RETURNS text
98
+ LANGUAGE sql
99
+ IMMUTABLE STRICT
100
+ SET search_path = auth, public, pg_temp
101
+ AS $$
102
+ SELECT auth.jwt_sign_raw(p_claims::text, p_secret);
103
+ $$;
104
+
105
+ COMMENT ON FUNCTION auth.jwt_sign(jsonb, text) IS
106
+ 'Sign a JWT HS256 token. Convenience over jwt_sign_raw — jsonb sorts keys alphabetically.';
107
+
108
+ -- -----------------------------------------------------------------------------
109
+ -- auth.jwt_verify(token text, secret text) → jsonb
110
+ --
111
+ -- Verifies HS256 signature, header constraints, and exp. Returns claims
112
+ -- jsonb on success, NULL on any failure. Never raises.
113
+ -- -----------------------------------------------------------------------------
114
+ CREATE OR REPLACE FUNCTION auth.jwt_verify(p_token text, p_secret text)
115
+ RETURNS jsonb
116
+ LANGUAGE plpgsql
117
+ STABLE
118
+ SET search_path = auth, public, pg_temp
119
+ AS $$
120
+ DECLARE
121
+ v_parts text[];
122
+ v_header jsonb;
123
+ v_expected_sig text;
124
+ v_claims jsonb;
125
+ v_exp bigint;
126
+ BEGIN
127
+ IF p_token IS NULL OR p_secret IS NULL THEN
128
+ RETURN NULL;
129
+ END IF;
130
+
131
+ v_parts := string_to_array(p_token, '.');
132
+ IF array_length(v_parts, 1) IS DISTINCT FROM 3 THEN
133
+ RETURN NULL;
134
+ END IF;
135
+
136
+ -- Header check: must be exactly HS256/JWT (no alg confusion, no alg=none).
137
+ BEGIN
138
+ v_header := convert_from(auth.base64url_decode(v_parts[1]), 'UTF8')::jsonb;
139
+ EXCEPTION WHEN OTHERS THEN
140
+ RETURN NULL;
141
+ END;
142
+ IF v_header->>'alg' IS DISTINCT FROM 'HS256'
143
+ OR v_header->>'typ' IS DISTINCT FROM 'JWT' THEN
144
+ RETURN NULL;
145
+ END IF;
146
+
147
+ -- Recompute signature from header.payload bytes.
148
+ v_expected_sig := auth.base64url_encode(
149
+ hmac(
150
+ convert_to(v_parts[1] || '.' || v_parts[2], 'UTF8'),
151
+ convert_to(p_secret, 'UTF8'),
152
+ 'sha256'
153
+ )
154
+ );
155
+ IF v_parts[3] IS DISTINCT FROM v_expected_sig THEN
156
+ RETURN NULL;
157
+ END IF;
158
+
159
+ -- Decode payload.
160
+ BEGIN
161
+ v_claims := convert_from(auth.base64url_decode(v_parts[2]), 'UTF8')::jsonb;
162
+ EXCEPTION WHEN OTHERS THEN
163
+ RETURN NULL;
164
+ END;
165
+
166
+ -- exp required (epoch seconds).
167
+ v_exp := nullif(v_claims->>'exp', '')::bigint;
168
+ IF v_exp IS NULL OR v_exp < extract(epoch FROM now())::bigint THEN
169
+ RETURN NULL;
170
+ END IF;
171
+
172
+ RETURN v_claims;
173
+ END;
174
+ $$;
175
+
176
+ COMMENT ON FUNCTION auth.jwt_verify(text, text) IS
177
+ 'Verify HS256 JWT signature + header + exp. Returns claims jsonb or NULL.';
@@ -0,0 +1,7 @@
1
+ export type { OAuthProvider, OAuthUserInfo } from './types';
2
+ export { verifyOidcToken, createJwksCache } from './verify';
3
+ export type { OidcClaims, JwksCache, JwksFetcher, JwksKey, JwksResponse } from './verify';
4
+ export { createGoogleProvider } from './providers/google';
5
+ export type { GoogleProviderConfig } from './providers/google';
6
+ export { createAppleProvider } from './providers/apple';
7
+ export type { AppleProviderConfig } from './providers/apple';
@@ -0,0 +1,43 @@
1
+ import { verifyOidcToken, createJwksCache } from '../verify';
2
+ import type { JwksCache } from '../verify';
3
+ import type { OAuthProvider } from '../types';
4
+
5
+ const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
6
+ const APPLE_ISSUER = 'https://appleid.apple.com';
7
+
8
+ export interface AppleProviderConfig {
9
+ clientId: string;
10
+ jwks?: JwksCache;
11
+ }
12
+
13
+ export function createAppleProvider(
14
+ configOrClientId: AppleProviderConfig | string
15
+ ): OAuthProvider {
16
+ const config: AppleProviderConfig =
17
+ typeof configOrClientId === 'string' ? { clientId: configOrClientId } : configOrClientId;
18
+ const jwks =
19
+ config.jwks ??
20
+ createJwksCache({
21
+ fetcher: async () => {
22
+ const res = await fetch(APPLE_JWKS_URI);
23
+ if (!res.ok) throw new Error(`apple jwks fetch failed: ${res.status}`);
24
+ return res.json();
25
+ },
26
+ });
27
+
28
+ return {
29
+ name: 'apple',
30
+ verify: async (token) => {
31
+ const claims = await verifyOidcToken(token, {
32
+ issuer: APPLE_ISSUER,
33
+ audience: config.clientId,
34
+ jwks,
35
+ });
36
+ if (!claims) return null;
37
+ const email = claims.email as string | undefined;
38
+ if (!email) return null;
39
+ // Apple only sends name on first authorization response, never in id_token.
40
+ return { uid: claims.sub, email };
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,49 @@
1
+ import { verifyOidcToken, createJwksCache } from '../verify';
2
+ import type { JwksCache } from '../verify';
3
+ import type { OAuthProvider } from '../types';
4
+
5
+ const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
6
+ const GOOGLE_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
7
+
8
+ export interface GoogleProviderConfig {
9
+ clientId: string;
10
+ /** Optional pre-configured JWKS cache. Defaults to fetching Google's JWKS. */
11
+ jwks?: JwksCache;
12
+ }
13
+
14
+ export function createGoogleProvider(
15
+ configOrClientId: GoogleProviderConfig | string
16
+ ): OAuthProvider {
17
+ const config: GoogleProviderConfig =
18
+ typeof configOrClientId === 'string' ? { clientId: configOrClientId } : configOrClientId;
19
+ const jwks =
20
+ config.jwks ??
21
+ createJwksCache({
22
+ fetcher: async () => {
23
+ const res = await fetch(GOOGLE_JWKS_URI);
24
+ if (!res.ok) throw new Error(`google jwks fetch failed: ${res.status}`);
25
+ return res.json();
26
+ },
27
+ });
28
+
29
+ return {
30
+ name: 'google',
31
+ verify: async (token) => {
32
+ const claims = await verifyOidcToken(token, {
33
+ issuer: GOOGLE_ISSUERS,
34
+ audience: config.clientId,
35
+ jwks,
36
+ });
37
+ if (!claims) return null;
38
+ const email = claims.email as string | undefined;
39
+ if (!email) return null;
40
+ return {
41
+ uid: claims.sub,
42
+ email,
43
+ firstName: claims.given_name as string | undefined,
44
+ lastName: claims.family_name as string | undefined,
45
+ avatarUrl: claims.picture as string | undefined,
46
+ };
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OAuth provider interface — implementations return normalized user info
3
+ * from a verified provider id_token.
4
+ */
5
+
6
+ export interface OAuthUserInfo {
7
+ uid: string;
8
+ email: string;
9
+ firstName?: string;
10
+ lastName?: string;
11
+ avatarUrl?: string;
12
+ }
13
+
14
+ export interface OAuthProvider {
15
+ name: string;
16
+ verify: (token: string) => Promise<OAuthUserInfo | null>;
17
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * RS256 + JWKS verifier — zero runtime deps, uses Web Crypto SubtleCrypto.
3
+ *
4
+ * Designed for OIDC id_token verification (Google, Apple, GitHub, etc.).
5
+ *
6
+ * Security:
7
+ * - Algorithm pinned to RS256. Rejects alg=none, alg=HS256 (algorithm
8
+ * confusion), and any other alg.
9
+ * - Issuer and audience strictly validated.
10
+ * - exp and nbf checked with optional clock skew.
11
+ * - JWKS cached with TTL; on missing kid, force-refreshes once.
12
+ */
13
+
14
+ export interface OidcClaims {
15
+ iss: string;
16
+ aud: string | string[];
17
+ sub: string;
18
+ exp: number;
19
+ nbf?: number;
20
+ iat?: number;
21
+ [k: string]: unknown;
22
+ }
23
+
24
+ export interface JwksKey extends JsonWebKey {
25
+ kid?: string;
26
+ alg?: string;
27
+ use?: string;
28
+ }
29
+
30
+ export interface JwksResponse {
31
+ keys: JwksKey[];
32
+ }
33
+
34
+ export type JwksFetcher = () => Promise<JwksResponse>;
35
+
36
+ export interface JwksCache {
37
+ /** Get the JWK for `kid`, refreshing if missing and not refreshed in this call. */
38
+ getKey(kid: string): Promise<JwksKey | null>;
39
+ }
40
+
41
+ export interface JwksCacheConfig {
42
+ fetcher: JwksFetcher;
43
+ /** Cache lifetime. Default 1 hour. */
44
+ ttlMs?: number;
45
+ }
46
+
47
+ export function createJwksCache(config: JwksCacheConfig): JwksCache {
48
+ const ttlMs = config.ttlMs ?? 3_600_000;
49
+ let cached: { keys: Map<string, JwksKey>; fetchedAt: number } | null = null;
50
+ let inflight: Promise<void> | null = null;
51
+
52
+ async function refresh(): Promise<void> {
53
+ if (inflight) return inflight;
54
+ inflight = (async () => {
55
+ const res = await config.fetcher();
56
+ const map = new Map<string, JwksKey>();
57
+ for (const key of res.keys) {
58
+ if (key.kid) map.set(key.kid, key);
59
+ }
60
+ cached = { keys: map, fetchedAt: Date.now() };
61
+ })();
62
+ try {
63
+ await inflight;
64
+ } finally {
65
+ inflight = null;
66
+ }
67
+ }
68
+
69
+ return {
70
+ async getKey(kid: string): Promise<JwksKey | null> {
71
+ if (!cached || Date.now() - cached.fetchedAt > ttlMs) {
72
+ await refresh();
73
+ }
74
+ let key = cached!.keys.get(kid);
75
+ if (!key) {
76
+ // Force refresh — could be a key rotation we haven't seen yet.
77
+ await refresh();
78
+ key = cached!.keys.get(kid);
79
+ }
80
+ return key ?? null;
81
+ },
82
+ };
83
+ }
84
+
85
+ export interface VerifyOidcOptions {
86
+ issuer: string | string[];
87
+ audience: string | string[];
88
+ jwks: JwksCache;
89
+ /** Allowed clock skew in seconds. Default 30. */
90
+ clockSkewSec?: number;
91
+ }
92
+
93
+ export async function verifyOidcToken(
94
+ token: string,
95
+ options: VerifyOidcOptions
96
+ ): Promise<OidcClaims | null> {
97
+ if (typeof token !== 'string' || token.length === 0) return null;
98
+ const parts = token.split('.');
99
+ if (parts.length !== 3) return null;
100
+ const [headerB64, payloadB64, sigB64] = parts;
101
+
102
+ // Header: pin alg=RS256.
103
+ let header: { alg?: string; kid?: string; typ?: string };
104
+ try {
105
+ header = JSON.parse(b64urlDecodeText(headerB64));
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (header.alg !== 'RS256' || !header.kid) return null;
110
+
111
+ const jwk = await options.jwks.getKey(header.kid);
112
+ if (!jwk) return null;
113
+
114
+ // Import as RSA-SHA256 verify key.
115
+ let cryptoKey: CryptoKey;
116
+ try {
117
+ cryptoKey = await crypto.subtle.importKey(
118
+ 'jwk',
119
+ jwk as JsonWebKey,
120
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
121
+ false,
122
+ ['verify']
123
+ );
124
+ } catch {
125
+ return null;
126
+ }
127
+
128
+ let sig: Uint8Array;
129
+ try {
130
+ sig = b64urlDecode(sigB64);
131
+ } catch {
132
+ return null;
133
+ }
134
+ const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
135
+
136
+ const ok = await crypto.subtle.verify(
137
+ { name: 'RSASSA-PKCS1-v1_5' },
138
+ cryptoKey,
139
+ sig as BufferSource,
140
+ data as BufferSource
141
+ );
142
+ if (!ok) return null;
143
+
144
+ let claims: OidcClaims;
145
+ try {
146
+ claims = JSON.parse(b64urlDecodeText(payloadB64)) as OidcClaims;
147
+ } catch {
148
+ return null;
149
+ }
150
+
151
+ const skew = options.clockSkewSec ?? 30;
152
+ const now = Math.floor(Date.now() / 1000);
153
+ if (typeof claims.exp !== 'number' || claims.exp + skew < now) return null;
154
+ if (typeof claims.nbf === 'number' && claims.nbf - skew > now) return null;
155
+
156
+ if (!matchesString(claims.iss, options.issuer)) return null;
157
+ if (!matchesAudience(claims.aud, options.audience)) return null;
158
+
159
+ if (typeof claims.sub !== 'string' || !claims.sub) return null;
160
+
161
+ return claims;
162
+ }
163
+
164
+ function matchesString(value: unknown, allowed: string | string[]): boolean {
165
+ if (typeof value !== 'string') return false;
166
+ return Array.isArray(allowed) ? allowed.includes(value) : allowed === value;
167
+ }
168
+
169
+ function matchesAudience(
170
+ claim: string | string[] | undefined,
171
+ expected: string | string[]
172
+ ): boolean {
173
+ if (claim == null) return false;
174
+ const claims = Array.isArray(claim) ? claim : [claim];
175
+ const allowed = Array.isArray(expected) ? expected : [expected];
176
+ return claims.some((c) => allowed.includes(c));
177
+ }
178
+
179
+ function b64urlDecodeText(s: string): string {
180
+ return new TextDecoder().decode(b64urlDecode(s));
181
+ }
182
+
183
+ function b64urlDecode(s: string): Uint8Array {
184
+ const std = s.replace(/-/g, '+').replace(/_/g, '/');
185
+ const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
186
+ const bin =
187
+ typeof atob === 'function'
188
+ ? atob(std + pad)
189
+ : Buffer.from(std + pad, 'base64').toString('binary');
190
+ const out = new Uint8Array(bin.length);
191
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
192
+ return out;
193
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @everystack/auth/oauth — OAuth provider verification.
3
+ *
4
+ * Back-compat re-export. New code should import from '@everystack/auth/oauth'.
5
+ */
6
+ export * from './oauth/index';