@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/session.ts ADDED
@@ -0,0 +1,154 @@
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
+
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { authError } from "./errors.ts";
22
+ import { b64urlEncode, toHex } from "./encoding.ts";
23
+
24
+ export interface SessionRecordData {
25
+ key: string; // hashToken(token) — the store key
26
+ userId: string;
27
+ expiresAt: number; // epoch ms
28
+ attributes: unknown; // app data (role, display name, ...)
29
+ }
30
+
31
+ // Generic-typed surface: `SessionRecord<A>` is SessionRecordData with the
32
+ // attributes member re-typed. No functions hold records of this shape by
33
+ // generic signature — see the phantom-alias note above.
34
+ export interface SessionRecord<A> extends SessionRecordData {
35
+ attributes: A | null;
36
+ }
37
+
38
+ export interface SessionStoreCore {
39
+ // Each member may return the value OR a Promise of it; `unknown` keeps the
40
+ // union SC2009-free. Implementations return the concrete shapes below.
41
+ get(key: string): unknown; // SessionRecordData | null | Promise<...>
42
+ set(record: SessionRecordData): unknown; // void | Promise<void>
43
+ updateExpiry(key: string, expiresAt: number): unknown; // void | Promise<void>
44
+ delete(key: string): unknown; // void | Promise<void>
45
+ deleteAllForUser(userId: string): unknown; // void | Promise<void>
46
+ }
47
+
48
+ // Phantom generic: `SessionStore<A>` types the intended attribute record
49
+ // while every method lowers identically to SessionStoreCore.
50
+ export interface SessionStore<_A> extends SessionStoreCore {}
51
+
52
+ export interface SessionOpts {
53
+ ttlSeconds?: number; // default: 30 days
54
+ // extend when less than this remains; default: half the TTL
55
+ extensionThresholdSeconds?: number;
56
+ // deterministic clock seam for tests/ELF (default Date.now())
57
+ nowMs?: number;
58
+ }
59
+
60
+ const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
61
+
62
+ export function generateSessionToken(): string {
63
+ return b64urlEncode(randomBytes(16)) + "." + b64urlEncode(randomBytes(32));
64
+ }
65
+
66
+ // SHA-256 hex of the full token. This is the store key and the only thing
67
+ // persisted; exported so store adapters key consistently.
68
+ export function hashToken(token: string): string {
69
+ return toHex(createHash("sha256").update(token).digest());
70
+ }
71
+
72
+ function optsOr(opts: SessionOpts | undefined): SessionOpts {
73
+ if (opts === undefined || opts === null) {
74
+ return {};
75
+ }
76
+ return opts;
77
+ }
78
+
79
+ function ttlOf(opts: SessionOpts): number {
80
+ if (opts.ttlSeconds === undefined) {
81
+ return DEFAULT_TTL_SECONDS;
82
+ }
83
+ return opts.ttlSeconds;
84
+ }
85
+
86
+ function nowOf(opts: SessionOpts): number {
87
+ if (opts.nowMs === undefined) {
88
+ return Date.now();
89
+ }
90
+ return opts.nowMs;
91
+ }
92
+
93
+ export async function createSession(
94
+ store: SessionStoreCore,
95
+ token: string,
96
+ userId: string,
97
+ attributes: unknown,
98
+ opts?: SessionOpts,
99
+ ): Promise<SessionRecordData> {
100
+ const o = optsOr(opts);
101
+ const ttl = ttlOf(o);
102
+ if (!(ttl > 0)) {
103
+ throw authError("E_BAD_PARAMS", "ttlSeconds must be positive");
104
+ }
105
+ const rec: SessionRecordData = {
106
+ key: hashToken(token),
107
+ userId: userId,
108
+ expiresAt: nowOf(o) + ttl * 1000,
109
+ attributes: attributes,
110
+ };
111
+ await store.set(rec);
112
+ return rec;
113
+ }
114
+
115
+ // Sliding expiry: a valid session past the extension threshold gets
116
+ // expiresAt bumped and written back. Returns null for unknown, expired, or
117
+ // malformed tokens. Never throws.
118
+ export async function validateSessionToken(
119
+ store: SessionStoreCore,
120
+ token: string,
121
+ opts?: SessionOpts,
122
+ ): Promise<SessionRecordData | null> {
123
+ const o = optsOr(opts);
124
+ const ttl = ttlOf(o);
125
+ let threshold = Math.floor(ttl / 2);
126
+ if (o.extensionThresholdSeconds !== undefined) {
127
+ threshold = o.extensionThresholdSeconds;
128
+ }
129
+ const now = nowOf(o);
130
+ const rec = (await store.get(hashToken(token))) as SessionRecordData | null;
131
+ if (rec === null || rec === undefined) {
132
+ return null;
133
+ }
134
+ if (now >= rec.expiresAt) {
135
+ await store.delete(rec.key);
136
+ return null;
137
+ }
138
+ if (rec.expiresAt - now < threshold * 1000) {
139
+ rec.expiresAt = now + ttl * 1000;
140
+ await store.updateExpiry(rec.key, rec.expiresAt);
141
+ }
142
+ return rec;
143
+ }
144
+
145
+ export async function invalidateSession(store: SessionStoreCore, token: string): Promise<void> {
146
+ await store.delete(hashToken(token));
147
+ }
148
+
149
+ export async function invalidateAllUserSessions(
150
+ store: SessionStoreCore,
151
+ userId: string,
152
+ ): Promise<void> {
153
+ await store.deleteAllForUser(userId);
154
+ }
package/src/totp.ts ADDED
@@ -0,0 +1,132 @@
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
+
5
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { base32Decode, base32Encode } from "./encoding.ts";
7
+
8
+ const DEFAULT_DIGITS = 6;
9
+ const DEFAULT_STEP = 30;
10
+ const DEFAULT_WINDOW = 1;
11
+ const SECRET_BYTES = 20;
12
+
13
+ export function generateTotpSecret(): string {
14
+ return base32Encode(randomBytes(SECRET_BYTES));
15
+ }
16
+
17
+ export interface TotpUriOpts {
18
+ digits?: number; // default 6
19
+ stepSeconds?: number; // default 30
20
+ }
21
+
22
+ // otpauth:// URI for authenticator enrollment (Google Authenticator shape).
23
+ export function totpAuthUri(
24
+ secret: string,
25
+ accountName: string,
26
+ issuer: string,
27
+ opts?: TotpUriOpts,
28
+ ): string {
29
+ let digits = DEFAULT_DIGITS;
30
+ let step = DEFAULT_STEP;
31
+ if (opts !== undefined && opts !== null) {
32
+ if (opts.digits !== undefined) {
33
+ digits = opts.digits;
34
+ }
35
+ if (opts.stepSeconds !== undefined) {
36
+ step = opts.stepSeconds;
37
+ }
38
+ }
39
+ const label = encodeURIComponent(issuer + ":" + accountName);
40
+ return (
41
+ "otpauth://totp/" +
42
+ label +
43
+ "?secret=" +
44
+ secret +
45
+ "&issuer=" +
46
+ encodeURIComponent(issuer) +
47
+ "&digits=" +
48
+ digits +
49
+ "&period=" +
50
+ step
51
+ );
52
+ }
53
+
54
+ // RFC 4226 dynamic truncation: 8-byte big-endian counter -> HMAC-SHA1 ->
55
+ // 4-byte window -> digits.
56
+ function hotpCode(key: Uint8Array, counter: number, digits: number): string {
57
+ const msg = new Uint8Array(8);
58
+ let hi = Math.floor(counter / 4294967296);
59
+ let lo = counter % 4294967296;
60
+ msg[0] = (hi >>> 24) & 255;
61
+ msg[1] = (hi >>> 16) & 255;
62
+ msg[2] = (hi >>> 8) & 255;
63
+ msg[3] = hi & 255;
64
+ msg[4] = (lo >>> 24) & 255;
65
+ msg[5] = (lo >>> 16) & 255;
66
+ msg[6] = (lo >>> 8) & 255;
67
+ msg[7] = lo & 255;
68
+ const mac = createHmac("sha1", key).update(msg).digest();
69
+ const off = mac[19] & 15;
70
+ const bin =
71
+ ((mac[off] & 127) << 24) |
72
+ ((mac[off + 1] & 255) << 16) |
73
+ ((mac[off + 2] & 255) << 8) |
74
+ (mac[off + 3] & 255);
75
+ const mod = Math.pow(10, digits);
76
+ let s = "" + (bin % mod);
77
+ while (s.length < digits) {
78
+ s = "0" + s;
79
+ }
80
+ return s;
81
+ }
82
+
83
+ export interface VerifyTotpOpts {
84
+ window?: number; // default ±1 step
85
+ stepSeconds?: number; // default 30
86
+ digits?: number; // default 6
87
+ nowMs?: number; // deterministic clock seam for tests/ELF (default Date.now())
88
+ }
89
+
90
+ export function verifyTotp(code: string, secret: string, opts?: VerifyTotpOpts): boolean {
91
+ const key = base32Decode(secret);
92
+ if (key === null) {
93
+ return false;
94
+ }
95
+ let digits = DEFAULT_DIGITS;
96
+ let step = DEFAULT_STEP;
97
+ let win = DEFAULT_WINDOW;
98
+ let nowMs = Date.now();
99
+ if (opts !== undefined && opts !== null) {
100
+ if (opts.digits !== undefined) {
101
+ digits = opts.digits;
102
+ }
103
+ if (opts.stepSeconds !== undefined) {
104
+ step = opts.stepSeconds;
105
+ }
106
+ if (opts.window !== undefined) {
107
+ win = opts.window;
108
+ }
109
+ if (opts.nowMs !== undefined) {
110
+ nowMs = opts.nowMs;
111
+ }
112
+ }
113
+ if (code.length !== digits) {
114
+ return false;
115
+ }
116
+ const counter = Math.floor(nowMs / 1000 / step);
117
+ let match = false;
118
+ // Check the whole window without early exit — timing stays flat.
119
+ for (let w = -win; w <= win; w++) {
120
+ const c = counter + w;
121
+ if (c < 0) {
122
+ continue;
123
+ }
124
+ const expected = hotpCode(key, c, digits);
125
+ const a = Buffer.from(expected, "utf8");
126
+ const b = Buffer.from(code, "utf8");
127
+ if (a.length === b.length && timingSafeEqual(a, b)) {
128
+ match = true;
129
+ }
130
+ }
131
+ return match;
132
+ }
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
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
+
11
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
12
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
13
+ }
14
+
15
+ export namespace StandardSchemaV1 {
16
+ export interface Props<Input = unknown, Output = Input> {
17
+ readonly version: 1;
18
+ readonly vendor: string;
19
+ readonly types?: Types<Input, Output> | undefined;
20
+ // `unknown` (not `Result | Promise<Result>`): a Promise union arm is
21
+ // SC2009 in the subset, and every conforming validator — whose real
22
+ // signature returns `Result<Output> | Promise<Result<Output>>` — is
23
+ // still assignable to a function returning `unknown`. runSchema
24
+ // awaits the value and reads the shape at runtime.
25
+ readonly validate: (value: unknown) => unknown;
26
+ }
27
+ export interface Types<Input = unknown, Output = Input> {
28
+ readonly input: Input;
29
+ readonly output: Output;
30
+ }
31
+ export type Result<Output> = SuccessResult<Output> | FailureResult;
32
+ export interface SuccessResult<Output> {
33
+ readonly value: Output;
34
+ readonly issues?: undefined;
35
+ }
36
+ export interface FailureResult {
37
+ readonly issues: ReadonlyArray<Issue>;
38
+ }
39
+ export interface Issue {
40
+ readonly message: string;
41
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
42
+ }
43
+ export interface PathSegment {
44
+ readonly key: PropertyKey;
45
+ }
46
+ export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
47
+ Schema["~standard"]["types"]
48
+ >["input"];
49
+ export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
50
+ Schema["~standard"]["types"]
51
+ >["output"];
52
+ }
53
+
54
+ export type InferOutput<S extends StandardSchemaV1> = StandardSchemaV1.InferOutput<S>;
55
+
56
+ // Runs a Standard-Schema validator over untrusted input (JWT claims, OAuth
57
+ // userinfo). Returns the validated value or null — the auth verify-path
58
+ // contract is null-on-invalid, never throw. The validate call may return a
59
+ // Result or a Promise<Result>; awaiting unknown covers both without ever
60
+ // sniffing `.then` (SC2009).
61
+ export async function runSchema(schema: StandardSchemaV1, value: unknown): Promise<unknown | null> {
62
+ let res: unknown;
63
+ try {
64
+ res = await schema["~standard"].validate(value);
65
+ } catch {
66
+ return null;
67
+ }
68
+ if (res === null || typeof res !== "object") {
69
+ return null;
70
+ }
71
+ const r = res as { [k: string]: unknown };
72
+ const issues = r["issues"];
73
+ if (issues !== undefined && issues !== null) {
74
+ return null;
75
+ }
76
+ if (!("value" in r)) {
77
+ return null;
78
+ }
79
+ return r["value"];
80
+ }