@chttp/auth 0.0.0-stage → 0.2.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.
package/src/jwt.ts ADDED
@@ -0,0 +1,200 @@
1
+ // JWT — JWS compact serialization only. ~120 LoC over node:crypto; no JWE,
2
+ // no JWKS machinery, no EdDSA in v1 (follow-up tickets).
3
+ //
4
+ // - alg comes from the key type: string => HS256 secret, KeyObject =>
5
+ // ES256 P-256 (the spec's v2 path). Overridable via opts.alg; verify
6
+ // enforces the expected alg, so there is no alg-confusion window and
7
+ // "none" is hard-rejected.
8
+ // - ES256 IS RECOGNIZED BUT NOT LOWERABLE TODAY: scriptc 0.3.3's static
9
+ // node:crypto slice covers hashing, randomness and secret KeyObjects —
10
+ // createSign/createVerify/createPrivateKey/createPublicKey have no
11
+ // lowering (asymmetric ops need a public-key stack the runtime lacks).
12
+ // signJwt({ alg: "ES256" }) throws E_BAD_ALG and verifyJwt rejects ES256
13
+ // tokens with null; the `string | KeyObject` signature stays so the
14
+ // feature slots in upstream without a breaking change. When it lands it
15
+ // must sign/verify with dsaEncoding "ieee-p1363" (JWS carries raw R||S,
16
+ // not DER — the classic interop bug; RFC 7515 A.3 vector is in tests).
17
+ // - Verify is null-on-invalid for ANY failure: bad format, wrong alg, bad
18
+ // signature, expired/nbf, wrong issuer/audience. Never throws — callers
19
+ // branch on null instead of try/catching attacker input. Sign throws on
20
+ // caller bugs (missing/wrong key) because those are programmer errors.
21
+
22
+ import { createHmac, timingSafeEqual } from "node:crypto";
23
+ import type { KeyObject } from "node:crypto";
24
+ import { authError } from "./errors.ts";
25
+ import { b64urlDecode, b64urlEncode } from "./encoding.ts";
26
+ import { runSchema } from "./types.ts";
27
+ import type { StandardSchemaV1 } from "./types.ts";
28
+
29
+ export interface SignJwtOpts {
30
+ alg?: "HS256" | "ES256"; // inferred from key type if omitted; ES256 -> E_BAD_ALG
31
+ expiresInSeconds?: number; // sets exp = iat + this
32
+ issuer?: string;
33
+ audience?: string;
34
+ }
35
+
36
+ export interface VerifyJwtOpts {
37
+ alg?: "HS256" | "ES256"; // inferred from key type if omitted
38
+ issuer?: string;
39
+ audience?: string;
40
+ clockToleranceSeconds?: number; // default 0; applies to exp and nbf
41
+ }
42
+
43
+ function inferAlg(key: string | KeyObject): string {
44
+ return typeof key === "string" ? "HS256" : "ES256";
45
+ }
46
+
47
+ export function signJwt(
48
+ payload: { [k: string]: unknown },
49
+ key: string | KeyObject,
50
+ opts?: SignJwtOpts,
51
+ ): string {
52
+ const o = opts === undefined || opts === null ? {} : opts;
53
+ const alg = o.alg === undefined ? inferAlg(key) : o.alg;
54
+ if (alg === "ES256") {
55
+ throw authError(
56
+ "E_BAD_ALG",
57
+ "ES256 needs node:crypto asymmetric ops, which scriptc 0.3.3 does not lower",
58
+ );
59
+ }
60
+ if (alg !== "HS256") {
61
+ throw authError("E_BAD_ALG", "signJwt supports HS256 only");
62
+ }
63
+ if (typeof key !== "string") {
64
+ throw authError("E_NO_KEY", "HS256 requires a string secret");
65
+ }
66
+ const now = Math.floor(Date.now() / 1000);
67
+ const claims: { [k: string]: unknown } = {};
68
+ const keys = Object.keys(payload);
69
+ for (let i = 0; i < keys.length; i++) {
70
+ claims[keys[i]] = payload[keys[i]];
71
+ }
72
+ claims["iat"] = now;
73
+ if (o.expiresInSeconds !== undefined) {
74
+ claims["exp"] = now + o.expiresInSeconds;
75
+ }
76
+ if (o.issuer !== undefined) {
77
+ claims["iss"] = o.issuer;
78
+ }
79
+ if (o.audience !== undefined) {
80
+ claims["aud"] = o.audience;
81
+ }
82
+ const header = { alg: alg, typ: "JWT" };
83
+ const input =
84
+ b64urlEncode(Buffer.from(JSON.stringify(header), "utf8")) +
85
+ "." +
86
+ b64urlEncode(Buffer.from(JSON.stringify(claims), "utf8"));
87
+ const sig = createHmac("sha256", key).update(input).digest();
88
+ return input + "." + b64urlEncode(sig);
89
+ }
90
+
91
+ function audMatches(aud: unknown, expected: string): boolean {
92
+ if (typeof aud === "string") {
93
+ return aud === expected;
94
+ }
95
+ if (Array.isArray(aud)) {
96
+ for (let i = 0; i < aud.length; i++) {
97
+ if (aud[i] === expected) {
98
+ return true;
99
+ }
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // null on ANY failure: bad format, wrong alg, bad signature, expired, wrong
106
+ // issuer/audience. alg "none" always rejected. Never throws.
107
+ export function verifyJwt(
108
+ token: string,
109
+ key: string | KeyObject,
110
+ opts?: VerifyJwtOpts,
111
+ ): { [k: string]: unknown } | null {
112
+ const o = opts === undefined || opts === null ? {} : opts;
113
+ try {
114
+ const parts = token.split(".");
115
+ if (parts.length !== 3) {
116
+ return null;
117
+ }
118
+ const expectedAlg = o.alg === undefined ? inferAlg(key) : o.alg;
119
+ const headerRaw = JSON.parse(b64urlDecode(parts[0]).toString("utf8")) as {
120
+ [k: string]: unknown;
121
+ };
122
+ const alg = headerRaw["alg"];
123
+ if (alg !== expectedAlg) {
124
+ // Covers "none", alg-confusion, and any unsupported alg.
125
+ return null;
126
+ }
127
+ if (alg !== "HS256" || typeof key !== "string") {
128
+ // ES256 (or anything non-HS256) is unverifiable in this build; a
129
+ // KeyObject key has no usable signing surface either.
130
+ return null;
131
+ }
132
+ const input = parts[0] + "." + parts[1];
133
+ const sigBytes = b64urlDecode(parts[2]);
134
+ const expected = createHmac("sha256", key).update(input).digest();
135
+ // timingSafeEqual throws on length mismatch — length pre-check.
136
+ if (expected.length !== sigBytes.length) {
137
+ return null;
138
+ }
139
+ if (!timingSafeEqual(expected, sigBytes)) {
140
+ return null;
141
+ }
142
+ const claims = JSON.parse(b64urlDecode(parts[1]).toString("utf8")) as { [k: string]: unknown };
143
+ if (claims === null || typeof claims !== "object" || Array.isArray(claims)) {
144
+ return null;
145
+ }
146
+ const tol = o.clockToleranceSeconds === undefined ? 0 : o.clockToleranceSeconds;
147
+ const now = Math.floor(Date.now() / 1000);
148
+ const exp = claims["exp"];
149
+ if (exp !== undefined) {
150
+ if (typeof exp !== "number") {
151
+ return null;
152
+ }
153
+ if (now - tol >= exp) {
154
+ return null; // expired
155
+ }
156
+ }
157
+ const nbf = claims["nbf"];
158
+ if (nbf !== undefined) {
159
+ if (typeof nbf !== "number") {
160
+ return null;
161
+ }
162
+ if (now + tol < nbf) {
163
+ return null; // not yet valid
164
+ }
165
+ }
166
+ if (o.issuer !== undefined) {
167
+ if (claims["iss"] !== o.issuer) {
168
+ return null;
169
+ }
170
+ }
171
+ if (o.audience !== undefined) {
172
+ if (!audMatches(claims["aud"], o.audience)) {
173
+ return null;
174
+ }
175
+ }
176
+ return claims;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // val-first twin of verifyJwt: verifies, then parses the claims through any
183
+ // StandardSchemaV1 (@chttp/val, valibot, ...). Returns the typed claims or
184
+ // null — the positional-schema convention (`prepare(sql, schema)`).
185
+ export async function verifyJwtWith<Schema extends StandardSchemaV1>(
186
+ token: string,
187
+ key: string | KeyObject,
188
+ schema: Schema,
189
+ opts?: VerifyJwtOpts,
190
+ ): Promise<StandardSchemaV1.InferOutput<Schema> | null> {
191
+ const claims = verifyJwt(token, key, opts);
192
+ if (claims === null) {
193
+ return null;
194
+ }
195
+ const out = await runSchema(schema, claims);
196
+ if (out === null) {
197
+ return null;
198
+ }
199
+ return out as StandardSchemaV1.InferOutput<Schema>;
200
+ }
@@ -0,0 +1,75 @@
1
+ // MemorySessionStore — dev/test SessionStore over a Map (the 0.3.3 probe
2
+ // lifted the Map/Set ban; same exception @chttp/cache uses).
3
+ //
4
+ // NOT for production. Process memory loses sessions on restart and does not
5
+ // share them across replicas — ship a real adapter (the @cutils/sqlite
6
+ // `key TEXT PRIMARY KEY` shape) instead. Dev only.
7
+
8
+ import type { SessionRecordData } from "./session.ts";
9
+
10
+ export class MemorySessionStoreCore {
11
+ _records: Map<string, SessionRecordData>;
12
+ _now: () => number;
13
+
14
+ // `now` is a constructor-only test seam (fake clock), deliberately NOT an
15
+ // options-record member — function values inside records degrade
16
+ // instantiation under scriptc.
17
+ constructor(now?: () => number) {
18
+ this._records = new Map<string, SessionRecordData>();
19
+ this._now = now === undefined ? Date.now : now;
20
+ }
21
+
22
+ _sweep(key: string, record: SessionRecordData): SessionRecordData | null {
23
+ if (this._now() >= record.expiresAt) {
24
+ this._records.delete(key);
25
+ return null;
26
+ }
27
+ return record;
28
+ }
29
+
30
+ get(key: string): SessionRecordData | null {
31
+ const r = this._records.get(key);
32
+ if (r === undefined) {
33
+ return null;
34
+ }
35
+ return this._sweep(key, r);
36
+ }
37
+
38
+ set(record: SessionRecordData): void {
39
+ this._records.set(record.key, record);
40
+ }
41
+
42
+ updateExpiry(key: string, expiresAt: number): void {
43
+ const r = this._records.get(key);
44
+ if (r === undefined) {
45
+ return;
46
+ }
47
+ r.expiresAt = expiresAt;
48
+ }
49
+
50
+ delete(key: string): void {
51
+ this._records.delete(key);
52
+ }
53
+
54
+ deleteAllForUser(userId: string): void {
55
+ const doomed: string[] = [];
56
+ for (const e of this._records.entries()) {
57
+ if (e[1].userId === userId) {
58
+ doomed.push(e[0]);
59
+ }
60
+ }
61
+ for (let i = 0; i < doomed.length; i++) {
62
+ this._records.delete(doomed[i]);
63
+ }
64
+ }
65
+
66
+ get size(): number {
67
+ return this._records.size;
68
+ }
69
+ }
70
+
71
+ // Phantom-generic alias over the non-generic core class — generic class
72
+ // parameters degrade instances to methodless shells under scriptc 0.3.x
73
+ // (the `Cache<V>`/`CacheCore` pattern).
74
+ export interface MemorySessionStore<_A> extends MemorySessionStoreCore {}
75
+ export const MemorySessionStore = MemorySessionStoreCore;
package/src/oauth.ts ADDED
@@ -0,0 +1,238 @@
1
+ // OAuth2 authorization-code + PKCE (RFC 6749 + RFC 7636). Providers are
2
+ // plain records — no registry, no plugin bus; adding one is a 15-LoC record
3
+ // in user code. The token exchange is a POST x-www-form-urlencoded with
4
+ // HTTP-basic client auth; responses parse as JSON OR form-urlencoded (RFC
5
+ // 6749 permits both). Exchange/userinfo return null on non-2xx or missing
6
+ // fields — never throw, never surface provider error bodies as exceptions.
7
+
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { b64urlEncode } from "./encoding.ts";
10
+ import { runSchema } from "./types.ts";
11
+ import type { StandardSchemaV1 } from "./types.ts";
12
+
13
+ export interface OAuthProvider {
14
+ authorizationEndpoint: string;
15
+ tokenEndpoint: string;
16
+ userInfoEndpoint: string | null; // GitHub: non-OIDC /user works
17
+ scope: string[]; // joined with spaces
18
+ usePkce: boolean;
19
+ }
20
+
21
+ export function generateState(): string {
22
+ return b64urlEncode(randomBytes(32));
23
+ }
24
+
25
+ // RFC 7636: verifier is 43-128 chars of unreserved; base64url(32 bytes)
26
+ // yields exactly 43.
27
+ export function generateCodeVerifier(): string {
28
+ return b64urlEncode(randomBytes(32));
29
+ }
30
+
31
+ // S256: base64url(sha256(verifier)), no padding (Node's base64url is
32
+ // already unpadded).
33
+ export function codeChallenge(verifier: string): string {
34
+ return createHash("sha256").update(verifier).digest().toString("base64url");
35
+ }
36
+
37
+ export interface AuthorizeParams {
38
+ state: string;
39
+ codeVerifier: string | null; // ignored when provider.usePkce is false
40
+ extraQuery?: { [k: string]: string }; // e.g. { prompt: "consent" }
41
+ }
42
+
43
+ export function createAuthorizationURL(
44
+ provider: OAuthProvider,
45
+ clientId: string,
46
+ redirectURI: string,
47
+ params: AuthorizeParams,
48
+ ): string {
49
+ const url = new URL(provider.authorizationEndpoint);
50
+ url.searchParams.set("response_type", "code");
51
+ url.searchParams.set("client_id", clientId);
52
+ url.searchParams.set("redirect_uri", redirectURI);
53
+ url.searchParams.set("scope", provider.scope.join(" "));
54
+ url.searchParams.set("state", params.state);
55
+ if (provider.usePkce && params.codeVerifier !== null) {
56
+ url.searchParams.set("code_challenge", codeChallenge(params.codeVerifier));
57
+ url.searchParams.set("code_challenge_method", "S256");
58
+ }
59
+ if (params.extraQuery !== undefined && params.extraQuery !== null) {
60
+ const keys = Object.keys(params.extraQuery);
61
+ for (let i = 0; i < keys.length; i++) {
62
+ url.searchParams.set(keys[i], params.extraQuery[keys[i]]);
63
+ }
64
+ }
65
+ return url.toString();
66
+ }
67
+
68
+ export interface TokenSet {
69
+ access_token: string;
70
+ token_type: string;
71
+ refresh_token: string | null;
72
+ expires_in: number | null;
73
+ scope: string | null;
74
+ id_token: string | null;
75
+ raw: { [k: string]: unknown }; // full provider response
76
+ }
77
+
78
+ export interface ExchangeParams {
79
+ code: string;
80
+ clientId: string;
81
+ clientSecret: string | null;
82
+ redirectURI: string;
83
+ codeVerifier: string | null;
84
+ }
85
+
86
+ function strOrNull(v: unknown): string | null {
87
+ return typeof v === "string" ? v : null;
88
+ }
89
+
90
+ function numOrNull(v: unknown): number | null {
91
+ return typeof v === "number" ? v : null;
92
+ }
93
+
94
+ // Parses a token-endpoint body by content-type: JSON, else
95
+ // x-www-form-urlencoded (RFC 6749 permits both).
96
+ function parseTokenBody(contentType: string, text: string): { [k: string]: unknown } | null {
97
+ if (contentType.indexOf("json") >= 0) {
98
+ try {
99
+ const parsed = JSON.parse(text) as { [k: string]: unknown };
100
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ return null;
102
+ }
103
+ return parsed;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+ const params = new URLSearchParams(text);
109
+ const raw: { [k: string]: unknown } = {};
110
+ for (const name of params.keys()) {
111
+ const v = params.get(name);
112
+ if (v !== null) {
113
+ raw[name] = v;
114
+ }
115
+ }
116
+ if (raw["access_token"] === undefined) {
117
+ return null;
118
+ }
119
+ return raw;
120
+ }
121
+
122
+ export async function exchangeCodeForTokens(
123
+ provider: OAuthProvider,
124
+ params: ExchangeParams,
125
+ ): Promise<TokenSet | null> {
126
+ const body = new URLSearchParams();
127
+ body.set("grant_type", "authorization_code");
128
+ body.set("code", params.code);
129
+ body.set("redirect_uri", params.redirectURI);
130
+ body.set("client_id", params.clientId);
131
+ if (params.codeVerifier !== null) {
132
+ body.set("code_verifier", params.codeVerifier);
133
+ }
134
+ const headers: { [k: string]: string } = {};
135
+ headers["content-type"] = "application/x-www-form-urlencoded";
136
+ headers["accept"] = "application/json";
137
+ if (params.clientSecret !== null) {
138
+ // RFC 6749 §2.3.1: client auth via HTTP basic.
139
+ body.set("client_secret", params.clientSecret);
140
+ headers["authorization"] =
141
+ "Basic " +
142
+ Buffer.from(params.clientId + ":" + params.clientSecret, "utf8").toString("base64");
143
+ }
144
+ let res: Response;
145
+ try {
146
+ res = await fetch(provider.tokenEndpoint, {
147
+ method: "POST",
148
+ headers: headers,
149
+ body: body.toString(),
150
+ });
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (!res.ok) {
155
+ return null;
156
+ }
157
+ let text: string;
158
+ let contentType = "";
159
+ try {
160
+ const ct = res.headers.get("content-type");
161
+ contentType = ct === null ? "" : ct;
162
+ text = await res.text();
163
+ } catch {
164
+ return null;
165
+ }
166
+ const raw = parseTokenBody(contentType, text);
167
+ if (raw === null) {
168
+ return null;
169
+ }
170
+ const accessToken = raw["access_token"];
171
+ if (typeof accessToken !== "string" || accessToken === "") {
172
+ return null;
173
+ }
174
+ const out: TokenSet = {
175
+ access_token: accessToken,
176
+ token_type:
177
+ strOrNull(raw["token_type"]) === null ? "" : (strOrNull(raw["token_type"]) as string),
178
+ refresh_token: strOrNull(raw["refresh_token"]),
179
+ expires_in: numOrNull(raw["expires_in"]),
180
+ scope: strOrNull(raw["scope"]),
181
+ id_token: strOrNull(raw["id_token"]),
182
+ raw: raw,
183
+ };
184
+ return out;
185
+ }
186
+
187
+ export async function fetchUserInfo(
188
+ provider: OAuthProvider,
189
+ accessToken: string,
190
+ ): Promise<{ [k: string]: unknown } | null> {
191
+ if (provider.userInfoEndpoint === null) {
192
+ return null;
193
+ }
194
+ const headers: { [k: string]: string } = {};
195
+ headers["authorization"] = "Bearer " + accessToken;
196
+ headers["accept"] = "application/json";
197
+ let res: Response;
198
+ try {
199
+ res = await fetch(provider.userInfoEndpoint, { headers: headers });
200
+ } catch {
201
+ return null;
202
+ }
203
+ if (!res.ok) {
204
+ return null;
205
+ }
206
+ let text: string;
207
+ try {
208
+ text = await res.text();
209
+ } catch {
210
+ return null;
211
+ }
212
+ try {
213
+ const parsed = JSON.parse(text) as { [k: string]: unknown };
214
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
215
+ return null;
216
+ }
217
+ return parsed;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ // Schema-parsed userinfo — same StandardSchemaV1 seam as verifyJwtWith.
224
+ export async function fetchUserInfoWith<Schema extends StandardSchemaV1>(
225
+ provider: OAuthProvider,
226
+ accessToken: string,
227
+ schema: Schema,
228
+ ): Promise<StandardSchemaV1.InferOutput<Schema> | null> {
229
+ const info = await fetchUserInfo(provider, accessToken);
230
+ if (info === null) {
231
+ return null;
232
+ }
233
+ const out = await runSchema(schema, info);
234
+ if (out === null) {
235
+ return null;
236
+ }
237
+ return out as StandardSchemaV1.InferOutput<Schema>;
238
+ }
@@ -0,0 +1,116 @@
1
+ // scrypt password hashing, self-describing format:
2
+ // "scrypt:N:r:p:saltB64:hashB64"
3
+ // verifyPassword reads the parameters from the string, so parameter upgrades
4
+ // don't invalidate old hashes; hashPassword writes today's OWASP defaults
5
+ // (N=2^17, r=8, p=1, 64-byte key, 16-byte salt). The format prefix leaves
6
+ // room for a future memory-hard successor (e.g. argon2) to slot in.
7
+
8
+ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
9
+
10
+ const DEFAULT_N = 131072; // 2^17 — OWASP Password Storage Cheat Sheet
11
+ const DEFAULT_R = 8;
12
+ const DEFAULT_P = 1;
13
+ const KEYLEN = 64;
14
+ const SALT_BYTES = 16;
15
+ // scrypt memory is ~128*N*r bytes: 2^17*8*128 = 128 MiB, past Node's 32 MiB
16
+ // default maxmem — raised explicitly.
17
+ const MAXMEM = 256 * 1024 * 1024;
18
+
19
+ export interface PasswordOpts {
20
+ N?: number;
21
+ r?: number;
22
+ p?: number;
23
+ }
24
+
25
+ export function hashPassword(password: string, opts?: PasswordOpts): string {
26
+ let N = DEFAULT_N;
27
+ let r = DEFAULT_R;
28
+ let p = DEFAULT_P;
29
+ if (opts !== undefined && opts !== null) {
30
+ if (opts.N !== undefined) {
31
+ N = opts.N;
32
+ }
33
+ if (opts.r !== undefined) {
34
+ r = opts.r;
35
+ }
36
+ if (opts.p !== undefined) {
37
+ p = opts.p;
38
+ }
39
+ }
40
+ const salt = randomBytes(SALT_BYTES);
41
+ const hash = scryptSync(password, salt, KEYLEN, {
42
+ N: N,
43
+ r: r,
44
+ p: p,
45
+ maxmem: MAXMEM,
46
+ });
47
+ return (
48
+ "scrypt:" +
49
+ N +
50
+ ":" +
51
+ r +
52
+ ":" +
53
+ p +
54
+ ":" +
55
+ salt.toString("base64") +
56
+ ":" +
57
+ hash.toString("base64")
58
+ );
59
+ }
60
+
61
+ // Burns one scrypt at default parameters so malformed/corrupt stored rows
62
+ // cost the same as a real verify — flattens the oracle between "row doesn't
63
+ // parse" and "password wrong".
64
+ function dummyHash(password: string): void {
65
+ scryptSync(password, Buffer.from("cutils-auth-dummy-salt", "utf8"), KEYLEN, {
66
+ N: DEFAULT_N,
67
+ r: DEFAULT_R,
68
+ p: DEFAULT_P,
69
+ maxmem: MAXMEM,
70
+ });
71
+ }
72
+
73
+ function parseParam(s: string): number {
74
+ if (!/^[0-9]+$/.test(s)) {
75
+ return -1;
76
+ }
77
+ return Number(s);
78
+ }
79
+
80
+ export function verifyPassword(password: string, stored: string): boolean {
81
+ const parts = stored.split(":");
82
+ if (parts.length !== 6 || parts[0] !== "scrypt") {
83
+ dummyHash(password);
84
+ return false;
85
+ }
86
+ const N = parseParam(parts[1]);
87
+ const r = parseParam(parts[2]);
88
+ const p = parseParam(parts[3]);
89
+ if (N <= 0 || r <= 0 || p <= 0) {
90
+ dummyHash(password);
91
+ return false;
92
+ }
93
+ const salt = Buffer.from(parts[4], "base64");
94
+ const expected = Buffer.from(parts[5], "base64");
95
+ if (salt.length === 0 || expected.length === 0) {
96
+ dummyHash(password);
97
+ return false;
98
+ }
99
+ let hash: Buffer;
100
+ try {
101
+ hash = scryptSync(password, salt, expected.length, {
102
+ N: N,
103
+ r: r,
104
+ p: p,
105
+ maxmem: MAXMEM,
106
+ });
107
+ } catch {
108
+ dummyHash(password);
109
+ return false;
110
+ }
111
+ // timingSafeEqual throws on length mismatch — length pre-check.
112
+ if (hash.length !== expected.length) {
113
+ return false;
114
+ }
115
+ return timingSafeEqual(hash, expected);
116
+ }
@@ -0,0 +1,32 @@
1
+ // Provider presets: plain records, ~15 LoC each — google, github, discord,
2
+ // the top three. A new provider is a 15-LoC record in user code; the
3
+ // package deliberately grows no registry (the better-auth trap).
4
+
5
+ import type { OAuthProvider } from "./oauth.ts";
6
+
7
+ // OIDC: openid email profile; PKCE on.
8
+ export const googleProvider: OAuthProvider = {
9
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
10
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
11
+ userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
12
+ scope: ["openid", "email", "profile"],
13
+ usePkce: true,
14
+ };
15
+
16
+ // Non-OIDC /user endpoint; PKCE is tolerated (ignored) by GitHub.
17
+ export const githubProvider: OAuthProvider = {
18
+ authorizationEndpoint: "https://github.com/login/oauth/authorize",
19
+ tokenEndpoint: "https://github.com/login/oauth/access_token",
20
+ userInfoEndpoint: "https://api.github.com/user",
21
+ scope: ["read:user", "user:email"],
22
+ usePkce: true,
23
+ };
24
+
25
+ // Discord supports PKCE for public clients (clientSecret may be null).
26
+ export const discordProvider: OAuthProvider = {
27
+ authorizationEndpoint: "https://discord.com/oauth2/authorize",
28
+ tokenEndpoint: "https://discord.com/api/oauth2/token",
29
+ userInfoEndpoint: "https://discord.com/api/users/@me",
30
+ scope: ["identify", "email"],
31
+ usePkce: true,
32
+ };