@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.
@@ -0,0 +1,94 @@
1
+ // Sessions, after the Lucia pattern: a random bearer token, a SHA-256 store
2
+ // key (the only thing persisted), sliding expiry. ~150 LoC because the
3
+ // package refuses to own state — the caller's store holds everything.
4
+ //
5
+ // Token layout: base64url(16-byte id) + "." + base64url(32-byte secret) —
6
+ // 384 bits of entropy, never stored whole. hashToken() produces the lookup
7
+ // key; a leaked store yields nothing replayable.
8
+ //
9
+ // Store contract is sync-or-async: stores may return values or Promises and
10
+ // the lifecycle functions `await` unconditionally (one microtask on sync
11
+ // stores — documented in the README; the SHA-256 dominates). We never sniff
12
+ // `.then` (SC2009). Interface members return `unknown` for that reason —
13
+ // a `T | Promise<T>` union arm is SC2009.
14
+ //
15
+ // `SessionStore<A>` / `SessionRecord<A>` are phantom-generic aliases: the
16
+ // generic parameter documents intended attribute type, but no method
17
+ // signature references it (a member that mentions the type param refuses
18
+ // to lower at the call site — SC1090, same finding as @chttp/cache).
19
+ import { createHash, randomBytes } from "node:crypto";
20
+ import { authError } from "./errors.js";
21
+ import { b64urlEncode, toHex } from "./encoding.js";
22
+ const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
23
+ export function generateSessionToken() {
24
+ return b64urlEncode(randomBytes(16)) + "." + b64urlEncode(randomBytes(32));
25
+ }
26
+ // SHA-256 hex of the full token. This is the store key and the only thing
27
+ // persisted; exported so store adapters key consistently.
28
+ export function hashToken(token) {
29
+ return toHex(createHash("sha256").update(token).digest());
30
+ }
31
+ function optsOr(opts) {
32
+ if (opts === undefined || opts === null) {
33
+ return {};
34
+ }
35
+ return opts;
36
+ }
37
+ function ttlOf(opts) {
38
+ if (opts.ttlSeconds === undefined) {
39
+ return DEFAULT_TTL_SECONDS;
40
+ }
41
+ return opts.ttlSeconds;
42
+ }
43
+ function nowOf(opts) {
44
+ if (opts.nowMs === undefined) {
45
+ return Date.now();
46
+ }
47
+ return opts.nowMs;
48
+ }
49
+ export async function createSession(store, token, userId, attributes, opts) {
50
+ const o = optsOr(opts);
51
+ const ttl = ttlOf(o);
52
+ if (!(ttl > 0)) {
53
+ throw authError("E_BAD_PARAMS", "ttlSeconds must be positive");
54
+ }
55
+ const rec = {
56
+ key: hashToken(token),
57
+ userId: userId,
58
+ expiresAt: nowOf(o) + ttl * 1000,
59
+ attributes: attributes,
60
+ };
61
+ await store.set(rec);
62
+ return rec;
63
+ }
64
+ // Sliding expiry: a valid session past the extension threshold gets
65
+ // expiresAt bumped and written back. Returns null for unknown, expired, or
66
+ // malformed tokens. Never throws.
67
+ export async function validateSessionToken(store, token, opts) {
68
+ const o = optsOr(opts);
69
+ const ttl = ttlOf(o);
70
+ let threshold = Math.floor(ttl / 2);
71
+ if (o.extensionThresholdSeconds !== undefined) {
72
+ threshold = o.extensionThresholdSeconds;
73
+ }
74
+ const now = nowOf(o);
75
+ const rec = (await store.get(hashToken(token)));
76
+ if (rec === null || rec === undefined) {
77
+ return null;
78
+ }
79
+ if (now >= rec.expiresAt) {
80
+ await store.delete(rec.key);
81
+ return null;
82
+ }
83
+ if (rec.expiresAt - now < threshold * 1000) {
84
+ rec.expiresAt = now + ttl * 1000;
85
+ await store.updateExpiry(rec.key, rec.expiresAt);
86
+ }
87
+ return rec;
88
+ }
89
+ export async function invalidateSession(store, token) {
90
+ await store.delete(hashToken(token));
91
+ }
92
+ export async function invalidateAllUserSessions(store, userId) {
93
+ await store.deleteAllForUser(userId);
94
+ }
@@ -0,0 +1,13 @@
1
+ export declare function generateTotpSecret(): string;
2
+ export interface TotpUriOpts {
3
+ digits?: number;
4
+ stepSeconds?: number;
5
+ }
6
+ export declare function totpAuthUri(secret: string, accountName: string, issuer: string, opts?: TotpUriOpts): string;
7
+ export interface VerifyTotpOpts {
8
+ window?: number;
9
+ stepSeconds?: number;
10
+ digits?: number;
11
+ nowMs?: number;
12
+ }
13
+ export declare function verifyTotp(code: string, secret: string, opts?: VerifyTotpOpts): boolean;
@@ -0,0 +1,106 @@
1
+ // TOTP (RFC 6238) over HMAC-SHA1 HOTP (RFC 4226), base32 secrets (RFC 4648).
2
+ // Defaults: 6 digits, 30 s step, ±1 window. Code compares are constant-time
3
+ // (length pre-check + timingSafeEqual over the digit strings).
4
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { base32Decode, base32Encode } from "./encoding.js";
6
+ const DEFAULT_DIGITS = 6;
7
+ const DEFAULT_STEP = 30;
8
+ const DEFAULT_WINDOW = 1;
9
+ const SECRET_BYTES = 20;
10
+ export function generateTotpSecret() {
11
+ return base32Encode(randomBytes(SECRET_BYTES));
12
+ }
13
+ // otpauth:// URI for authenticator enrollment (Google Authenticator shape).
14
+ export function totpAuthUri(secret, accountName, issuer, opts) {
15
+ let digits = DEFAULT_DIGITS;
16
+ let step = DEFAULT_STEP;
17
+ if (opts !== undefined && opts !== null) {
18
+ if (opts.digits !== undefined) {
19
+ digits = opts.digits;
20
+ }
21
+ if (opts.stepSeconds !== undefined) {
22
+ step = opts.stepSeconds;
23
+ }
24
+ }
25
+ const label = encodeURIComponent(issuer + ":" + accountName);
26
+ return ("otpauth://totp/" +
27
+ label +
28
+ "?secret=" +
29
+ secret +
30
+ "&issuer=" +
31
+ encodeURIComponent(issuer) +
32
+ "&digits=" +
33
+ digits +
34
+ "&period=" +
35
+ step);
36
+ }
37
+ // RFC 4226 dynamic truncation: 8-byte big-endian counter -> HMAC-SHA1 ->
38
+ // 4-byte window -> digits.
39
+ function hotpCode(key, counter, digits) {
40
+ const msg = new Uint8Array(8);
41
+ let hi = Math.floor(counter / 4294967296);
42
+ let lo = counter % 4294967296;
43
+ msg[0] = (hi >>> 24) & 255;
44
+ msg[1] = (hi >>> 16) & 255;
45
+ msg[2] = (hi >>> 8) & 255;
46
+ msg[3] = hi & 255;
47
+ msg[4] = (lo >>> 24) & 255;
48
+ msg[5] = (lo >>> 16) & 255;
49
+ msg[6] = (lo >>> 8) & 255;
50
+ msg[7] = lo & 255;
51
+ const mac = createHmac("sha1", key).update(msg).digest();
52
+ const off = mac[19] & 15;
53
+ const bin = ((mac[off] & 127) << 24) |
54
+ ((mac[off + 1] & 255) << 16) |
55
+ ((mac[off + 2] & 255) << 8) |
56
+ (mac[off + 3] & 255);
57
+ const mod = Math.pow(10, digits);
58
+ let s = "" + (bin % mod);
59
+ while (s.length < digits) {
60
+ s = "0" + s;
61
+ }
62
+ return s;
63
+ }
64
+ export function verifyTotp(code, secret, opts) {
65
+ const key = base32Decode(secret);
66
+ if (key === null) {
67
+ return false;
68
+ }
69
+ let digits = DEFAULT_DIGITS;
70
+ let step = DEFAULT_STEP;
71
+ let win = DEFAULT_WINDOW;
72
+ let nowMs = Date.now();
73
+ if (opts !== undefined && opts !== null) {
74
+ if (opts.digits !== undefined) {
75
+ digits = opts.digits;
76
+ }
77
+ if (opts.stepSeconds !== undefined) {
78
+ step = opts.stepSeconds;
79
+ }
80
+ if (opts.window !== undefined) {
81
+ win = opts.window;
82
+ }
83
+ if (opts.nowMs !== undefined) {
84
+ nowMs = opts.nowMs;
85
+ }
86
+ }
87
+ if (code.length !== digits) {
88
+ return false;
89
+ }
90
+ const counter = Math.floor(nowMs / 1000 / step);
91
+ let match = false;
92
+ // Check the whole window without early exit — timing stays flat.
93
+ for (let w = -win; w <= win; w++) {
94
+ const c = counter + w;
95
+ if (c < 0) {
96
+ continue;
97
+ }
98
+ const expected = hotpCode(key, c, digits);
99
+ const a = Buffer.from(expected, "utf8");
100
+ const b = Buffer.from(code, "utf8");
101
+ if (a.length === b.length && timingSafeEqual(a, b)) {
102
+ match = true;
103
+ }
104
+ }
105
+ return match;
106
+ }
@@ -0,0 +1,34 @@
1
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
2
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
3
+ }
4
+ export declare namespace StandardSchemaV1 {
5
+ interface Props<Input = unknown, Output = Input> {
6
+ readonly version: 1;
7
+ readonly vendor: string;
8
+ readonly types?: Types<Input, Output> | undefined;
9
+ readonly validate: (value: unknown) => unknown;
10
+ }
11
+ interface Types<Input = unknown, Output = Input> {
12
+ readonly input: Input;
13
+ readonly output: Output;
14
+ }
15
+ type Result<Output> = SuccessResult<Output> | FailureResult;
16
+ interface SuccessResult<Output> {
17
+ readonly value: Output;
18
+ readonly issues?: undefined;
19
+ }
20
+ interface FailureResult {
21
+ readonly issues: ReadonlyArray<Issue>;
22
+ }
23
+ interface Issue {
24
+ readonly message: string;
25
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
26
+ }
27
+ interface PathSegment {
28
+ readonly key: PropertyKey;
29
+ }
30
+ type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
31
+ type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
32
+ }
33
+ export type InferOutput<S extends StandardSchemaV1> = StandardSchemaV1.InferOutput<S>;
34
+ export declare function runSchema(schema: StandardSchemaV1, value: unknown): Promise<unknown | null>;
@@ -0,0 +1,35 @@
1
+ // Shared types for @chttp/auth.
2
+ //
3
+ // StandardSchemaV1 below is a structural twin of the interface published by
4
+ // @standard-schema/spec (Standard Schema v1). The npm package is types-only
5
+ // and scriptc refuses even `import type` of it in the compiled module graph
6
+ // (SC2013), so the twin is declared here — same approach as @chttp/mcp's
7
+ // src/types.ts. Structural identity means any real Standard Schema v1
8
+ // validator (@chttp/val, valibot, zod/v4 standard entry) is assignable to
9
+ // this twin and vice versa. The spec text: https://standardschema.dev
10
+ // Runs a Standard-Schema validator over untrusted input (JWT claims, OAuth
11
+ // userinfo). Returns the validated value or null — the auth verify-path
12
+ // contract is null-on-invalid, never throw. The validate call may return a
13
+ // Result or a Promise<Result>; awaiting unknown covers both without ever
14
+ // sniffing `.then` (SC2009).
15
+ export async function runSchema(schema, value) {
16
+ let res;
17
+ try {
18
+ res = await schema["~standard"].validate(value);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (res === null || typeof res !== "object") {
24
+ return null;
25
+ }
26
+ const r = res;
27
+ const issues = r["issues"];
28
+ if (issues !== undefined && issues !== null) {
29
+ return null;
30
+ }
31
+ if (!("value" in r)) {
32
+ return null;
33
+ }
34
+ return r["value"];
35
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@chttp/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Session, password, JWT, OAuth2+PKCE, CSRF and TOTP primitives over node:crypto, authored inside the scriptc compilable subset",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/sigilco/cutils.git",
9
+ "directory": "packages/auth"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "main": "./dist/esm/index.js",
17
+ "types": "./dist/esm/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/esm/index.d.ts",
21
+ "import": "./dist/esm/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "devDependencies": {
26
+ "@chttp/router": "^0.2.1",
27
+ "@chttp/val": "^0.2.1",
28
+ "@standard-schema/spec": "^1.1.0",
29
+ "@types/node": "^24.0.0",
30
+ "jose": "^6.0.0",
31
+ "typescript": "^5.9.0",
32
+ "valibot": "^1.5.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "author": "Joaquin Terrasa & contributors",
38
+ "keywords": [
39
+ "auth",
40
+ "session",
41
+ "jwt",
42
+ "oauth2",
43
+ "pkce",
44
+ "csrf",
45
+ "totp",
46
+ "scrypt",
47
+ "scriptc",
48
+ "native"
49
+ ],
50
+ "homepage": "https://github.com/sigilco/cutils/tree/main/packages/auth#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/sigilco/cutils/issues"
53
+ },
54
+ "sideEffects": false,
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.json",
57
+ "typecheck": "tsc -p tsconfig.json --noEmit",
58
+ "test": "npm run build && node --test test/*.test.js test/*.test.mjs && bash test/elf.test.sh"
59
+ }
6
60
  }
package/src/cookie.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Set-Cookie serialization + minimal Cookie-header read. Three cookies are
2
+ // set (session, expired-session, csrf) and one is read (session) — 80 LoC
3
+ // beats a jar dependency.
4
+
5
+ export interface CookieAttrs {
6
+ maxAge?: number; // seconds
7
+ path?: string; // default "/"
8
+ secure?: boolean;
9
+ httpOnly?: boolean;
10
+ sameSite?: "lax" | "strict" | "none";
11
+ }
12
+
13
+ export const SESSION_COOKIE_NAME = "session";
14
+ export const CSRF_COOKIE_NAME = "csrf";
15
+
16
+ function sameSiteAttr(v: string): string {
17
+ if (v === "strict") {
18
+ return "Strict";
19
+ }
20
+ if (v === "none") {
21
+ return "None";
22
+ }
23
+ return "Lax";
24
+ }
25
+
26
+ // Full Set-Cookie header value.
27
+ export function serializeCookie(name: string, value: string, attrs: CookieAttrs): string {
28
+ let out = name + "=" + value;
29
+ const path = attrs.path === undefined ? "/" : attrs.path;
30
+ out += "; Path=" + path;
31
+ if (attrs.maxAge !== undefined) {
32
+ out += "; Max-Age=" + Math.floor(attrs.maxAge);
33
+ }
34
+ if (attrs.httpOnly === true) {
35
+ out += "; HttpOnly";
36
+ }
37
+ if (attrs.secure === true) {
38
+ out += "; Secure";
39
+ }
40
+ if (attrs.sameSite !== undefined) {
41
+ out += "; SameSite=" + sameSiteAttr(attrs.sameSite);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // Minimal read of the Cookie request header: returns the raw value for
47
+ // `name`, or null. First match wins; values are returned undecoded (the
48
+ // session/csrf tokens this package sets are base64url, which needs none).
49
+ export function readCookie(cookieHeader: string | null, name: string): string | null {
50
+ if (cookieHeader === null) {
51
+ return null;
52
+ }
53
+ const prefix = name + "=";
54
+ const parts = cookieHeader.split(";");
55
+ for (let i = 0; i < parts.length; i++) {
56
+ const p = parts[i].trim();
57
+ if (p.startsWith(prefix)) {
58
+ return p.slice(prefix.length);
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export interface SessionCookieOpts {
65
+ secure?: boolean; // default true; set false for http://localhost dev
66
+ ttlSeconds?: number; // default: 30 days — bound to the session TTL
67
+ }
68
+
69
+ const DEFAULT_COOKIE_TTL = 30 * 24 * 60 * 60;
70
+
71
+ // Prebuilt session cookie: HttpOnly, SameSite=Lax, Path=/, Secure=true
72
+ // (override for localhost), Max-Age bound to the session TTL.
73
+ export function buildSessionCookie(token: string, opts?: SessionCookieOpts): string {
74
+ const secure = opts === undefined || opts.secure === undefined ? true : opts.secure;
75
+ const ttl =
76
+ opts === undefined || opts.ttlSeconds === undefined ? DEFAULT_COOKIE_TTL : opts.ttlSeconds;
77
+ return serializeCookie(SESSION_COOKIE_NAME, token, {
78
+ maxAge: ttl,
79
+ path: "/",
80
+ secure: secure,
81
+ httpOnly: true,
82
+ sameSite: "lax",
83
+ });
84
+ }
85
+
86
+ // Logout: same name, empty value, already expired.
87
+ export function buildExpiredSessionCookie(opts?: SessionCookieOpts): string {
88
+ const secure = opts === undefined || opts.secure === undefined ? true : opts.secure;
89
+ return serializeCookie(SESSION_COOKIE_NAME, "", {
90
+ maxAge: 0,
91
+ path: "/",
92
+ secure: secure,
93
+ httpOnly: true,
94
+ sameSite: "lax",
95
+ });
96
+ }
97
+
98
+ // CSRF double-submit cookie: deliberately NOT HttpOnly — the pattern needs
99
+ // JS (or the page itself) to read the token into a form field/header.
100
+ export function buildCsrfCookie(token: string, opts?: SessionCookieOpts): string {
101
+ const secure = opts === undefined || opts.secure === undefined ? true : opts.secure;
102
+ const ttl =
103
+ opts === undefined || opts.ttlSeconds === undefined ? DEFAULT_COOKIE_TTL : opts.ttlSeconds;
104
+ return serializeCookie(CSRF_COOKIE_NAME, token, {
105
+ maxAge: ttl,
106
+ path: "/",
107
+ secure: secure,
108
+ httpOnly: false,
109
+ sameSite: "lax",
110
+ });
111
+ }
package/src/csrf.ts ADDED
@@ -0,0 +1,46 @@
1
+ // CSRF: double-submit cookie + Origin allowlist — the two halves of the
2
+ // pattern. The token compare is constant-time (length pre-check, then
3
+ // timingSafeEqual); the origin allowlist is a plain string match — Origin
4
+ // is not secret material.
5
+
6
+ import { randomBytes, timingSafeEqual } from "node:crypto";
7
+
8
+ // base64url(32 random bytes).
9
+ export function generateCsrfToken(): string {
10
+ return randomBytes(32).toString("base64url");
11
+ }
12
+
13
+ // Double-submit: the cookie token and the submitted (form field / header)
14
+ // token must match byte-for-byte under a constant-time compare.
15
+ export function verifyCsrfToken(
16
+ cookieToken: string | null,
17
+ submittedToken: string | null,
18
+ ): boolean {
19
+ if (cookieToken === null || submittedToken === null) {
20
+ return false;
21
+ }
22
+ const a = Buffer.from(cookieToken, "utf8");
23
+ const b = Buffer.from(submittedToken, "utf8");
24
+ // timingSafeEqual throws on length mismatch — length pre-check.
25
+ if (a.length !== b.length) {
26
+ return false;
27
+ }
28
+ return timingSafeEqual(a, b);
29
+ }
30
+
31
+ // Origin check for state-changing requests (the other CSRF half): the
32
+ // request's Origin header must appear verbatim in the allowlist.
33
+ export function verifyRequestOrigin(
34
+ originHeader: string | null,
35
+ allowedOrigins: string[],
36
+ ): boolean {
37
+ if (originHeader === null) {
38
+ return false;
39
+ }
40
+ for (let i = 0; i < allowedOrigins.length; i++) {
41
+ if (allowedOrigins[i] === originHeader) {
42
+ return true;
43
+ }
44
+ }
45
+ return false;
46
+ }
@@ -0,0 +1,78 @@
1
+ // Byte <-> text helpers shared across the auth primitives.
2
+ //
3
+ // - Hex goes through a lookup table: `number.toString(16)` is SC2012-fenced
4
+ // under scriptc (probe finding from the hono/jwt attempt), and
5
+ // `Buffer.toString("hex")` on variable digests is avoided for the same
6
+ // class of reason — the table is deterministic.
7
+ // - base64url goes through Buffer (`Buffer.from(bytes).toString("base64url")`
8
+ // — probe-verified static in scriptc 0.3.3). Output is unpadded, which is
9
+ // what session tokens, PKCE challenges and JWS all require.
10
+ // - base32 is a 40-LoC RFC 4648 hand-roll (standard alphabet, no padding on
11
+ // encode, padding/lowercase tolerated on decode). It exists only for TOTP.
12
+
13
+ const HEX_DIGITS = "0123456789abcdef";
14
+
15
+ export function toHex(bytes: Uint8Array): string {
16
+ let out = "";
17
+ for (let i = 0; i < bytes.length; i++) {
18
+ const b = bytes[i];
19
+ out += HEX_DIGITS[b >>> 4] + HEX_DIGITS[b & 15];
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export function b64urlEncode(bytes: Uint8Array): string {
25
+ return Buffer.from(bytes).toString("base64url");
26
+ }
27
+
28
+ export function b64urlDecode(text: string): Buffer {
29
+ return Buffer.from(text, "base64url");
30
+ }
31
+
32
+ const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
33
+
34
+ // RFC 4648 base32, no padding emitted (otpauth secrets are unpadded).
35
+ export function base32Encode(bytes: Uint8Array): string {
36
+ let out = "";
37
+ let bits = 0;
38
+ let value = 0;
39
+ for (let i = 0; i < bytes.length; i++) {
40
+ value = (value << 8) | bytes[i];
41
+ bits += 8;
42
+ while (bits >= 5) {
43
+ out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
44
+ bits -= 5;
45
+ }
46
+ }
47
+ if (bits > 0) {
48
+ out += B32_ALPHABET[(value << (5 - bits)) & 31];
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Tolerates `=` padding and lowercase input; returns null on any character
54
+ // outside the RFC 4648 alphabet (null-on-invalid — this is attacker input
55
+ // when it comes from a stored secret or a user-typed key).
56
+ export function base32Decode(text: string): Uint8Array | null {
57
+ const upper = text.toUpperCase();
58
+ let bits = 0;
59
+ let value = 0;
60
+ const out: number[] = [];
61
+ for (let i = 0; i < upper.length; i++) {
62
+ const ch = upper[i];
63
+ if (ch === "=") {
64
+ break;
65
+ }
66
+ const idx = B32_ALPHABET.indexOf(ch);
67
+ if (idx < 0) {
68
+ return null;
69
+ }
70
+ value = (value << 5) | idx;
71
+ bits += 5;
72
+ if (bits >= 8) {
73
+ out.push((value >>> (bits - 8)) & 255);
74
+ bits -= 8;
75
+ }
76
+ }
77
+ return new Uint8Array(out);
78
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Code-string errors for caller bugs on the sign/exchange paths. Verify
2
+ // paths never throw — they return null/false — so these codes only fire for
3
+ // programmer error: missing key material, an alg the package does not sign,
4
+ // or malformed parameters.
5
+
6
+ export function authError(code: string, detail: string): Error {
7
+ return new Error(code + ": " + detail);
8
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ // @chttp/auth — public surface. Plain async functions over node:crypto:
2
+ // sessions (Lucia pattern), cookies, scrypt passwords, double-submit CSRF,
3
+ // HS256/ES256 JWT, OAuth2 authorization-code + PKCE, TOTP. Zero runtime
4
+ // dependencies; the schema seam is a local StandardSchemaV1 twin.
5
+
6
+ export {
7
+ generateSessionToken,
8
+ hashToken,
9
+ createSession,
10
+ validateSessionToken,
11
+ invalidateSession,
12
+ invalidateAllUserSessions,
13
+ } from "./session.ts";
14
+ export type {
15
+ SessionRecord,
16
+ SessionRecordData,
17
+ SessionStore,
18
+ SessionStoreCore,
19
+ SessionOpts,
20
+ } from "./session.ts";
21
+ export { MemorySessionStore, MemorySessionStoreCore } from "./memory-store.ts";
22
+
23
+ export {
24
+ serializeCookie,
25
+ readCookie,
26
+ buildSessionCookie,
27
+ buildExpiredSessionCookie,
28
+ buildCsrfCookie,
29
+ SESSION_COOKIE_NAME,
30
+ CSRF_COOKIE_NAME,
31
+ } from "./cookie.ts";
32
+ export type { CookieAttrs, SessionCookieOpts } from "./cookie.ts";
33
+
34
+ export { hashPassword, verifyPassword } from "./password.ts";
35
+ export type { PasswordOpts } from "./password.ts";
36
+
37
+ export { generateCsrfToken, verifyCsrfToken, verifyRequestOrigin } from "./csrf.ts";
38
+
39
+ export { signJwt, verifyJwt, verifyJwtWith } from "./jwt.ts";
40
+ export type { SignJwtOpts, VerifyJwtOpts } from "./jwt.ts";
41
+
42
+ export {
43
+ generateState,
44
+ generateCodeVerifier,
45
+ codeChallenge,
46
+ createAuthorizationURL,
47
+ exchangeCodeForTokens,
48
+ fetchUserInfo,
49
+ fetchUserInfoWith,
50
+ } from "./oauth.ts";
51
+ export type { OAuthProvider, TokenSet, AuthorizeParams, ExchangeParams } from "./oauth.ts";
52
+ export { googleProvider, githubProvider, discordProvider } from "./providers.ts";
53
+
54
+ export { generateTotpSecret, totpAuthUri, verifyTotp } from "./totp.ts";
55
+ export type { TotpUriOpts, VerifyTotpOpts } from "./totp.ts";
56
+
57
+ export { base32Encode, base32Decode, toHex, b64urlEncode, b64urlDecode } from "./encoding.ts";
58
+
59
+ export { authError } from "./errors.ts";
60
+ export { runSchema } from "./types.ts";
61
+ export type { StandardSchemaV1, InferOutput } from "./types.ts";