@starci/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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/client/auth-client.d.ts +68 -0
- package/dist/client/auth-client.js +192 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.js +4 -0
- package/dist/client/outcome.d.ts +15 -0
- package/dist/client/outcome.js +22 -0
- package/dist/client/session.d.ts +57 -0
- package/dist/client/session.js +68 -0
- package/dist/client/shared.d.ts +20 -0
- package/dist/client/shared.js +42 -0
- package/dist/contracts/index.d.ts +111 -0
- package/dist/contracts/index.js +71 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +42 -0
- package/dist/server/auth.module.d.ts +29 -0
- package/dist/server/auth.module.js +85 -0
- package/dist/server/auth.tokens.d.ts +24 -0
- package/dist/server/auth.tokens.js +24 -0
- package/dist/server/budget.d.ts +21 -0
- package/dist/server/budget.js +38 -0
- package/dist/server/cookie.d.ts +19 -0
- package/dist/server/cookie.js +54 -0
- package/dist/server/doors.d.ts +44 -0
- package/dist/server/doors.js +72 -0
- package/dist/server/errors.d.ts +24 -0
- package/dist/server/errors.js +18 -0
- package/dist/server/flows.d.ts +21 -0
- package/dist/server/flows.js +66 -0
- package/dist/server/google-state.d.ts +33 -0
- package/dist/server/google-state.js +73 -0
- package/dist/server/http.d.ts +27 -0
- package/dist/server/http.js +1 -0
- package/dist/server/index.d.ts +24 -0
- package/dist/server/index.js +15 -0
- package/dist/server/options.d.ts +81 -0
- package/dist/server/options.js +1 -0
- package/dist/server/passwords.d.ts +11 -0
- package/dist/server/passwords.js +22 -0
- package/dist/server/ports.d.ts +145 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/primitives.d.ts +36 -0
- package/dist/server/primitives.js +67 -0
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.js +5 -0
- package/dist/server/sessions.d.ts +45 -0
- package/dist/server/sessions.js +153 -0
- package/dist/server/strategies.d.ts +29 -0
- package/dist/server/strategies.js +55 -0
- package/dist/server/tokens.d.ts +26 -0
- package/dist/server/tokens.js +130 -0
- package/dist/server/verifier.d.ts +20 -0
- package/dist/server/verifier.js +23 -0
- package/dist/testing/index.d.ts +52 -0
- package/dist/testing/index.js +158 -0
- package/migrations/identity-sessions.sql +58 -0
- package/package.json +126 -4
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A value read out of a secret holder (the app's `Secret` class has this shape). */
|
|
2
|
+
export interface Revealable {
|
|
3
|
+
reveal(): string;
|
|
4
|
+
}
|
|
5
|
+
/** A secret given as text or as a holder the app already keeps. */
|
|
6
|
+
export type SecretInput = string | Revealable;
|
|
7
|
+
/** The text of a secret input. */
|
|
8
|
+
export declare const revealed: (secret: SecretInput) => string;
|
|
9
|
+
/** The signed state of one short round trip: the handle, the PKCE verifier, the landing path, the expiry and the purpose. */
|
|
10
|
+
export interface SignedState {
|
|
11
|
+
readonly w: string;
|
|
12
|
+
readonly u: string;
|
|
13
|
+
readonly r: string;
|
|
14
|
+
readonly e: number;
|
|
15
|
+
readonly p?: string;
|
|
16
|
+
}
|
|
17
|
+
/** A record: a non-null object that is not an array. */
|
|
18
|
+
export declare const isRecord: (value: unknown) => value is Readonly<Record<string, unknown>>;
|
|
19
|
+
/** A same-site path (one leading slash, no line break), else the fallback path. */
|
|
20
|
+
export declare const sameSitePath: (value: unknown, fallback: string) => string;
|
|
21
|
+
/** Signs a state: base64url(JSON).base64url(HMAC-SHA256). */
|
|
22
|
+
export declare const signStateToken: (key: Buffer, state: SignedState) => string;
|
|
23
|
+
/** The state when the signature is valid, the body is a state and it has not expired at `now`; otherwise null. */
|
|
24
|
+
export declare const verifyStateToken: (key: Buffer, value: string, now: number) => SignedState | null;
|
|
25
|
+
/** The SHA-256 hex of an opaque token or an address; only this form is stored or used as a counter key. */
|
|
26
|
+
export declare const digestOf: (value: string) => string;
|
|
27
|
+
/** The lower-cased, trimmed sign-in address. */
|
|
28
|
+
export declare const normalizeEmail: (email: string) => string;
|
|
29
|
+
/** True when a new password fits the policy: at least 8 characters, at most the 72 bytes bcrypt reads. */
|
|
30
|
+
export declare const isAcceptablePassword: (password: string) => boolean;
|
|
31
|
+
/** True when a presented password can possibly match (bcrypt reads 72 bytes at most); longer input is refused before hashing. */
|
|
32
|
+
export declare const isPresentablePassword: (password: string) => boolean;
|
|
33
|
+
/** True when two texts are equal, compared in constant time over equal-length buffers. */
|
|
34
|
+
export declare const sameValue: (given: string, expected: string) => boolean;
|
|
35
|
+
/** The value of one cookie in a `Cookie` request header (empty text when the header lacks it); the values we write are URL-safe and need no decoding. */
|
|
36
|
+
export declare const cookieValue: (header: string, name: string) => string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { PASSWORD_BOUNDS } from "../contracts/index.js";
|
|
3
|
+
/** The text of a secret input. */
|
|
4
|
+
export const revealed = (secret) => (typeof secret === "string" ? secret : secret.reveal());
|
|
5
|
+
/** A record: a non-null object that is not an array. */
|
|
6
|
+
export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
const SAME_SITE_PATH = /^\/(?![/\\])/u;
|
|
8
|
+
const LINE_BREAK = /[\r\n]/u;
|
|
9
|
+
/** A same-site path (one leading slash, no line break), else the fallback path. */
|
|
10
|
+
export const sameSitePath = (value, fallback) => typeof value === "string" && SAME_SITE_PATH.test(value) && !LINE_BREAK.test(value) ? value : fallback;
|
|
11
|
+
const encode = (buffer) => buffer.toString("base64url");
|
|
12
|
+
const isSignedState = (value) => isRecord(value) &&
|
|
13
|
+
typeof value.w === "string" &&
|
|
14
|
+
typeof value.u === "string" &&
|
|
15
|
+
typeof value.r === "string" &&
|
|
16
|
+
typeof value.e === "number" &&
|
|
17
|
+
(value.p === undefined || typeof value.p === "string");
|
|
18
|
+
/** Signs a state: base64url(JSON).base64url(HMAC-SHA256). */
|
|
19
|
+
export const signStateToken = (key, state) => {
|
|
20
|
+
const body = encode(Buffer.from(JSON.stringify(state)));
|
|
21
|
+
return `${body}.${encode(createHmac("sha256", key).update(body).digest())}`;
|
|
22
|
+
};
|
|
23
|
+
const parseJson = (text) => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** The state when the signature is valid, the body is a state and it has not expired at `now`; otherwise null. */
|
|
32
|
+
export const verifyStateToken = (key, value, now) => {
|
|
33
|
+
if (value === "")
|
|
34
|
+
return null;
|
|
35
|
+
const [body, signature, extra] = value.split(".");
|
|
36
|
+
if (body === undefined || body === "" || signature === undefined || signature === "" || extra !== undefined)
|
|
37
|
+
return null;
|
|
38
|
+
const expected = createHmac("sha256", key).update(body).digest();
|
|
39
|
+
const given = Buffer.from(signature, "base64url");
|
|
40
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
41
|
+
return null;
|
|
42
|
+
const state = parseJson(Buffer.from(body, "base64url").toString("utf8"));
|
|
43
|
+
return isSignedState(state) && state.e >= now ? state : null;
|
|
44
|
+
};
|
|
45
|
+
/** The SHA-256 hex of an opaque token or an address; only this form is stored or used as a counter key. */
|
|
46
|
+
export const digestOf = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
47
|
+
/** The lower-cased, trimmed sign-in address. */
|
|
48
|
+
export const normalizeEmail = (email) => email.trim().toLowerCase();
|
|
49
|
+
/** True when a new password fits the policy: at least 8 characters, at most the 72 bytes bcrypt reads. */
|
|
50
|
+
export const isAcceptablePassword = (password) => Array.from(password).length >= PASSWORD_BOUNDS.min && Buffer.byteLength(password, "utf8") <= PASSWORD_BOUNDS.bcryptMaxBytes;
|
|
51
|
+
/** True when a presented password can possibly match (bcrypt reads 72 bytes at most); longer input is refused before hashing. */
|
|
52
|
+
export const isPresentablePassword = (password) => password.length > 0 && Buffer.byteLength(password, "utf8") <= PASSWORD_BOUNDS.bcryptMaxBytes;
|
|
53
|
+
/** True when two texts are equal, compared in constant time over equal-length buffers. */
|
|
54
|
+
export const sameValue = (given, expected) => {
|
|
55
|
+
const left = Buffer.from(given);
|
|
56
|
+
const right = Buffer.from(expected);
|
|
57
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
58
|
+
};
|
|
59
|
+
/** The value of one cookie in a `Cookie` request header (empty text when the header lacks it); the values we write are URL-safe and need no decoding. */
|
|
60
|
+
export const cookieValue = (header, name) => {
|
|
61
|
+
for (const part of header.split(";")) {
|
|
62
|
+
const separator = part.indexOf("=");
|
|
63
|
+
if (separator > 0 && part.slice(0, separator).trim() === name)
|
|
64
|
+
return part.slice(separator + 1).trim();
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { JwksDocument } from "../contracts/index.js";
|
|
2
|
+
import type { AuthOptions } from "./options.js";
|
|
3
|
+
import type { AuthClock, AuthIds, AuthLogger, AuthSubject, DeviceMeta, SessionTokens } from "./ports.js";
|
|
4
|
+
import type { AuthTokens } from "./tokens.js";
|
|
5
|
+
/** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
|
|
6
|
+
export declare const DEFAULT_REUSE_GRACE_MS = 30000;
|
|
7
|
+
/** The log event of a replayed refresh token. */
|
|
8
|
+
export declare const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
|
|
9
|
+
/** What the sessions need from the module. */
|
|
10
|
+
export interface SessionsDeps {
|
|
11
|
+
readonly options: Pick<AuthOptions, "refreshTtlSeconds" | "reuseGraceMs" | "sessions" | "accounts">;
|
|
12
|
+
readonly tokens: AuthTokens;
|
|
13
|
+
readonly clock: AuthClock;
|
|
14
|
+
readonly ids: AuthIds;
|
|
15
|
+
readonly logger?: AuthLogger | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
|
|
19
|
+
* at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
|
|
20
|
+
* is left as the winner wrote it.
|
|
21
|
+
*/
|
|
22
|
+
export declare class AuthSessions {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(deps: SessionsDeps);
|
|
25
|
+
/** Opens a device session for a person who just proved who they are, and answers its first tokens. */
|
|
26
|
+
start(subject: AuthSubject, device: DeviceMeta): Promise<SessionTokens>;
|
|
27
|
+
/**
|
|
28
|
+
* Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
|
|
29
|
+
* ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
|
|
30
|
+
* `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
|
|
31
|
+
*/
|
|
32
|
+
refresh(presented: string): Promise<SessionTokens>;
|
|
33
|
+
/**
|
|
34
|
+
* Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
|
|
35
|
+
* can ask at once. A token rotated within the grace window still counts (a sibling request just rotated it); an older replay
|
|
36
|
+
* ends the session.
|
|
37
|
+
*/
|
|
38
|
+
access(presented: string): Promise<SessionTokens>;
|
|
39
|
+
/** The public key document access tokens verify against. */
|
|
40
|
+
jwks(): Promise<JwksDocument>;
|
|
41
|
+
/** Signs the person out of every other device; the session of the caller stays. */
|
|
42
|
+
endOthers(userId: string, keepSessionId: string | undefined): Promise<void>;
|
|
43
|
+
/** Ends the session a refresh token belongs to (an empty or unknown token changes nothing). */
|
|
44
|
+
end(presented: string): Promise<void>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { AUTH_ERROR_CODES } from "../contracts/index.js";
|
|
2
|
+
import { AuthError } from "./errors.js";
|
|
3
|
+
import { digestOf } from "./primitives.js";
|
|
4
|
+
/** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
|
|
5
|
+
export const DEFAULT_REUSE_GRACE_MS = 30_000;
|
|
6
|
+
/** The log event of a replayed refresh token. */
|
|
7
|
+
export const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
|
|
8
|
+
const MILLISECONDS = 1000;
|
|
9
|
+
/**
|
|
10
|
+
* Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
|
|
11
|
+
* at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
|
|
12
|
+
* is left as the winner wrote it.
|
|
13
|
+
*/
|
|
14
|
+
export class AuthSessions {
|
|
15
|
+
#deps;
|
|
16
|
+
constructor(deps) {
|
|
17
|
+
this.#deps = deps;
|
|
18
|
+
}
|
|
19
|
+
get #store() {
|
|
20
|
+
return this.#deps.options.sessions;
|
|
21
|
+
}
|
|
22
|
+
get #graceMs() {
|
|
23
|
+
return this.#deps.options.reuseGraceMs ?? DEFAULT_REUSE_GRACE_MS;
|
|
24
|
+
}
|
|
25
|
+
/** Opens a device session for a person who just proved who they are, and answers its first tokens. */
|
|
26
|
+
async start(subject, device) {
|
|
27
|
+
const now = this.#deps.clock.now();
|
|
28
|
+
const sessionId = this.#deps.ids.next();
|
|
29
|
+
const refreshToken = this.#mintToken();
|
|
30
|
+
await this.#store.transaction(async (tx) => {
|
|
31
|
+
await tx.openSession({ id: sessionId, userId: subject.userId, now, userAgent: device.userAgent, ip: device.ip });
|
|
32
|
+
await tx.insertToken({
|
|
33
|
+
id: this.#deps.ids.next(),
|
|
34
|
+
sessionId,
|
|
35
|
+
userId: subject.userId,
|
|
36
|
+
tokenHash: digestOf(refreshToken),
|
|
37
|
+
expiresAt: this.#refreshExpiry(now),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return this.#tokensFor(subject, sessionId, refreshToken, now);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
|
|
44
|
+
* ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
|
|
45
|
+
* `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
|
|
46
|
+
*/
|
|
47
|
+
async refresh(presented) {
|
|
48
|
+
if (presented === "")
|
|
49
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
|
|
50
|
+
const now = this.#deps.clock.now();
|
|
51
|
+
const nextToken = this.#mintToken();
|
|
52
|
+
const rotation = await this.#store.transaction(async (tx) => {
|
|
53
|
+
const row = await tx.findTokenForUpdate(digestOf(presented));
|
|
54
|
+
if (row === null || row.expiresAt <= now)
|
|
55
|
+
return { kind: "invalid" };
|
|
56
|
+
if (row.usedAt !== null) {
|
|
57
|
+
return now.getTime() - row.usedAt.getTime() <= this.#graceMs
|
|
58
|
+
? { kind: "raced", row }
|
|
59
|
+
: { kind: "reused", sessionId: row.sessionId };
|
|
60
|
+
}
|
|
61
|
+
await tx.markTokenUsed(row.id, now);
|
|
62
|
+
await tx.insertToken({
|
|
63
|
+
id: this.#deps.ids.next(),
|
|
64
|
+
sessionId: row.sessionId,
|
|
65
|
+
userId: row.userId,
|
|
66
|
+
tokenHash: digestOf(nextToken),
|
|
67
|
+
expiresAt: this.#refreshExpiry(now),
|
|
68
|
+
});
|
|
69
|
+
await tx.deleteExpiredTokens(row.userId, now);
|
|
70
|
+
return { kind: "rotated", row, next: nextToken };
|
|
71
|
+
});
|
|
72
|
+
if (rotation.kind === "reused") {
|
|
73
|
+
await this.#store.closeSession(rotation.sessionId);
|
|
74
|
+
this.#deps.logger?.warn(REFRESH_REUSE_EVENT, { sessionId: rotation.sessionId });
|
|
75
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshReused);
|
|
76
|
+
}
|
|
77
|
+
if (rotation.kind === "invalid")
|
|
78
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
|
|
79
|
+
if (rotation.kind === "raced")
|
|
80
|
+
return this.#accessOf(rotation.row, presented, now);
|
|
81
|
+
const user = await this.#deps.options.accounts.find(rotation.row.userId);
|
|
82
|
+
if (user === undefined || user.disabled || user.email === null) {
|
|
83
|
+
await this.#store.closeSession(rotation.row.sessionId);
|
|
84
|
+
throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
|
|
85
|
+
}
|
|
86
|
+
await this.#store.touchSession(rotation.row.sessionId, now);
|
|
87
|
+
return this.#tokensFor({ userId: user.id, email: user.email }, rotation.row.sessionId, rotation.next, now);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
|
|
91
|
+
* can ask at once. A token rotated within the grace window still counts (a sibling request just rotated it); an older replay
|
|
92
|
+
* ends the session.
|
|
93
|
+
*/
|
|
94
|
+
async access(presented) {
|
|
95
|
+
if (presented === "")
|
|
96
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
|
|
97
|
+
const now = this.#deps.clock.now();
|
|
98
|
+
const row = await this.#store.findToken(digestOf(presented));
|
|
99
|
+
if (row === null || row.expiresAt <= now)
|
|
100
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
|
|
101
|
+
if (row.usedAt !== null && now.getTime() - row.usedAt.getTime() > this.#graceMs) {
|
|
102
|
+
await this.#store.closeSession(row.sessionId);
|
|
103
|
+
this.#deps.logger?.warn(REFRESH_REUSE_EVENT, { sessionId: row.sessionId });
|
|
104
|
+
throw new AuthError(AUTH_ERROR_CODES.RefreshReused);
|
|
105
|
+
}
|
|
106
|
+
return this.#accessOf(row, presented, now);
|
|
107
|
+
}
|
|
108
|
+
/** The public key document access tokens verify against. */
|
|
109
|
+
jwks() {
|
|
110
|
+
return this.#deps.tokens.jwks();
|
|
111
|
+
}
|
|
112
|
+
/** Signs the person out of every other device; the session of the caller stays. */
|
|
113
|
+
async endOthers(userId, keepSessionId) {
|
|
114
|
+
if (keepSessionId === undefined)
|
|
115
|
+
throw new AuthError(AUTH_ERROR_CODES.Unauthenticated);
|
|
116
|
+
await this.#store.closeOtherSessions(userId, keepSessionId);
|
|
117
|
+
}
|
|
118
|
+
/** Ends the session a refresh token belongs to (an empty or unknown token changes nothing). */
|
|
119
|
+
async end(presented) {
|
|
120
|
+
if (presented === "")
|
|
121
|
+
return;
|
|
122
|
+
const row = await this.#store.findToken(digestOf(presented));
|
|
123
|
+
if (row !== null)
|
|
124
|
+
await this.#store.closeSession(row.sessionId);
|
|
125
|
+
}
|
|
126
|
+
/** An access token for the session of a token row, without rotating; an account that is gone or disabled ends the session. */
|
|
127
|
+
async #accessOf(row, presented, now) {
|
|
128
|
+
const user = await this.#deps.options.accounts.find(row.userId);
|
|
129
|
+
if (user === undefined || user.disabled || user.email === null) {
|
|
130
|
+
await this.#store.closeSession(row.sessionId);
|
|
131
|
+
throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
|
|
132
|
+
}
|
|
133
|
+
const tokens = await this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
|
|
134
|
+
return { ...tokens, cookieKept: true };
|
|
135
|
+
}
|
|
136
|
+
async #tokensFor(subject, sessionId, refreshToken, now) {
|
|
137
|
+
const access = await this.#deps.tokens.sign({ sub: subject.userId, email: subject.email, sessionId }, now);
|
|
138
|
+
return {
|
|
139
|
+
accessToken: access.token,
|
|
140
|
+
accessExpiresAt: access.expiresAt.toISOString(),
|
|
141
|
+
accessExpiresIn: Math.round((access.expiresAt.getTime() - now.getTime()) / MILLISECONDS),
|
|
142
|
+
cookieToken: refreshToken,
|
|
143
|
+
refreshMaxAgeSeconds: this.#deps.options.refreshTtlSeconds,
|
|
144
|
+
user: { id: subject.userId, email: subject.email },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
#refreshExpiry(now) {
|
|
148
|
+
return new Date(now.getTime() + this.#deps.options.refreshTtlSeconds * MILLISECONDS);
|
|
149
|
+
}
|
|
150
|
+
#mintToken() {
|
|
151
|
+
return `${this.#deps.ids.next()}${this.#deps.ids.next()}`.replaceAll("-", "");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|
2
|
+
import type { Profile } from "passport-google-oauth20";
|
|
3
|
+
import { Strategy as LocalStrategy } from "passport-local";
|
|
4
|
+
import type { GoogleStateClient } from "./google-state.js";
|
|
5
|
+
import type { GoogleOptions } from "./options.js";
|
|
6
|
+
import type { AuthAuthenticator, AuthSubject, GoogleProfile } from "./ports.js";
|
|
7
|
+
/** The facts the app needs from a Google profile; an account without a subject is refused. */
|
|
8
|
+
export declare const googleProfileOf: (profile: Profile) => GoogleProfile;
|
|
9
|
+
declare const PassportLocalStrategy_base: new (...args: [] | [options: import("passport-local").IStrategyOptionsWithRequest] | [options: import("passport-local").IStrategyOptions]) => LocalStrategy & {
|
|
10
|
+
validate(...args: any[]): unknown;
|
|
11
|
+
};
|
|
12
|
+
/** The email and password strategy; the app decides whether the pair is right. */
|
|
13
|
+
export declare class PassportLocalStrategy extends PassportLocalStrategy_base {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(authenticator: AuthAuthenticator);
|
|
16
|
+
/** Called by passport with the posted pair; answers the person or throws the app's refusal. */
|
|
17
|
+
validate(email: string, password: string): Promise<AuthSubject>;
|
|
18
|
+
}
|
|
19
|
+
declare const PassportGoogleStrategy_base: new (...args: [options: import("passport-google-oauth20").StrategyOptionsWithRequest] | [options: import("passport-google-oauth20").StrategyOptions] | [options: import("passport-google-oauth20").StrategyOptions] | [options: import("passport-google-oauth20").StrategyOptionsWithRequest]) => GoogleStrategy & {
|
|
20
|
+
validate(...args: any[]): unknown;
|
|
21
|
+
};
|
|
22
|
+
/** The Google OAuth 2.0 strategy: authorization code with `state` and PKCE (S256), the round trip held in a signed cookie. */
|
|
23
|
+
export declare class PassportGoogleStrategy extends PassportGoogleStrategy_base {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(google: GoogleOptions, authenticator: AuthAuthenticator, state: GoogleStateClient);
|
|
26
|
+
/** Called by passport with Google's profile; answers the person the app stands it for. */
|
|
27
|
+
validate(_accessToken: string, _refreshToken: string, profile: Profile): Promise<AuthSubject>;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PassportStrategy } from "@nestjs/passport";
|
|
2
|
+
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|
3
|
+
import { Strategy as LocalStrategy } from "passport-local";
|
|
4
|
+
import { AUTH_ERROR_CODES } from "../contracts/index.js";
|
|
5
|
+
import { AuthError } from "./errors.js";
|
|
6
|
+
import { revealed } from "./primitives.js";
|
|
7
|
+
const verifiedOf = (profile) => {
|
|
8
|
+
const claim = profile._json.email_verified;
|
|
9
|
+
return claim === true || claim === "true";
|
|
10
|
+
};
|
|
11
|
+
/** The facts the app needs from a Google profile; an account without a subject is refused. */
|
|
12
|
+
export const googleProfileOf = (profile) => {
|
|
13
|
+
if (profile.id === "")
|
|
14
|
+
throw new AuthError(AUTH_ERROR_CODES.GoogleRejected);
|
|
15
|
+
return {
|
|
16
|
+
subject: profile.id,
|
|
17
|
+
email: profile.emails?.[0]?.value ?? null,
|
|
18
|
+
emailVerified: verifiedOf(profile),
|
|
19
|
+
name: profile.displayName === "" ? null : profile.displayName,
|
|
20
|
+
picture: profile.photos?.[0]?.value ?? null,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** The email and password strategy; the app decides whether the pair is right. */
|
|
24
|
+
export class PassportLocalStrategy extends PassportStrategy(LocalStrategy, "local") {
|
|
25
|
+
#authenticator;
|
|
26
|
+
constructor(authenticator) {
|
|
27
|
+
super({ usernameField: "email", passwordField: "password", session: false });
|
|
28
|
+
this.#authenticator = authenticator;
|
|
29
|
+
}
|
|
30
|
+
/** Called by passport with the posted pair; answers the person or throws the app's refusal. */
|
|
31
|
+
validate(email, password) {
|
|
32
|
+
return this.#authenticator.verifyPassword(email, password);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The Google OAuth 2.0 strategy: authorization code with `state` and PKCE (S256), the round trip held in a signed cookie. */
|
|
36
|
+
export class PassportGoogleStrategy extends PassportStrategy(GoogleStrategy, "google") {
|
|
37
|
+
#authenticator;
|
|
38
|
+
constructor(google, authenticator, state) {
|
|
39
|
+
super({
|
|
40
|
+
clientID: google.clientId,
|
|
41
|
+
clientSecret: revealed(google.clientSecret),
|
|
42
|
+
callbackURL: google.callbackUrl,
|
|
43
|
+
scope: ["openid", "email", "profile"],
|
|
44
|
+
state: true,
|
|
45
|
+
pkce: true,
|
|
46
|
+
});
|
|
47
|
+
this.#authenticator = authenticator;
|
|
48
|
+
// passport-oauth2 types describe only its session stores; the stateless PKCE store replaces the one it built.
|
|
49
|
+
Object.assign(this, { _stateStore: state });
|
|
50
|
+
}
|
|
51
|
+
/** Called by passport with Google's profile; answers the person the app stands it for. */
|
|
52
|
+
validate(_accessToken, _refreshToken, profile) {
|
|
53
|
+
return this.#authenticator.signInWithGoogle(googleProfileOf(profile));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JwksDocument } from "../contracts/index.js";
|
|
2
|
+
import type { Outcome } from "./errors.js";
|
|
3
|
+
import type { AuthOptions } from "./options.js";
|
|
4
|
+
import type { AccessClaims, IssuedAccessToken } from "./ports.js";
|
|
5
|
+
import type { SignedState } from "./primitives.js";
|
|
6
|
+
/** The options this signer needs. */
|
|
7
|
+
export type TokenOptions = Pick<AuthOptions, "issuer" | "audience" | "keys" | "accessTtlSeconds">;
|
|
8
|
+
/**
|
|
9
|
+
* The one signer and verifier of our access tokens (Ed25519 JWTs, the algorithm pinned) and of the short-lived Google state.
|
|
10
|
+
* The key set holds one signing key and any number of retired keys: all of them verify and are published as the JWKS, so a key
|
|
11
|
+
* rotates without signing anybody out (add the new key first, make it the signer, drop the old one when its tokens have expired).
|
|
12
|
+
*/
|
|
13
|
+
export declare class AuthTokens {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(options: TokenOptions);
|
|
16
|
+
/** Signs an access token for one device session. */
|
|
17
|
+
sign(claims: AccessClaims, now: Date): Promise<IssuedAccessToken>;
|
|
18
|
+
/** The claims of a token that passed signature (against any key of the set), issuer, audience and expiry, or a refusal. */
|
|
19
|
+
verify(token: string, now: Date): Promise<Outcome<AccessClaims>>;
|
|
20
|
+
/** The public key document: every key of the set. */
|
|
21
|
+
jwks(): Promise<JwksDocument>;
|
|
22
|
+
/** Signs the state of a Google round trip (an HMAC under a key derived from the signing key). */
|
|
23
|
+
signState(state: SignedState): string;
|
|
24
|
+
/** The state when the value is ours, unexpired and untampered; otherwise null. */
|
|
25
|
+
verifyState(value: string, now: Date): SignedState | null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, createHmac, createPrivateKey, createPublicKey } from "node:crypto";
|
|
2
|
+
import { SignJWT, calculateJwkThumbprint, createLocalJWKSet, exportJWK, importPKCS8, importSPKI, jwtVerify } from "jose";
|
|
3
|
+
import { AUTH_ALGORITHM, AUTH_AUDIENCE, AUTH_CLAIMS, AUTH_ERROR_CODES, AUTH_ROLE } from "../contracts/index.js";
|
|
4
|
+
import { AuthError, ok, refused } from "./errors.js";
|
|
5
|
+
import { revealed, signStateToken, verifyStateToken } from "./primitives.js";
|
|
6
|
+
const STATE_LABEL = "passport.google-state";
|
|
7
|
+
const pemOf = (value) => value.replaceAll(String.raw `\n`, "\n");
|
|
8
|
+
/**
|
|
9
|
+
* The one signer and verifier of our access tokens (Ed25519 JWTs, the algorithm pinned) and of the short-lived Google state.
|
|
10
|
+
* The key set holds one signing key and any number of retired keys: all of them verify and are published as the JWKS, so a key
|
|
11
|
+
* rotates without signing anybody out (add the new key first, make it the signer, drop the old one when its tokens have expired).
|
|
12
|
+
*/
|
|
13
|
+
export class AuthTokens {
|
|
14
|
+
#options;
|
|
15
|
+
#ring;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.#options = options;
|
|
18
|
+
}
|
|
19
|
+
/** Signs an access token for one device session. */
|
|
20
|
+
async sign(claims, now) {
|
|
21
|
+
const { signer } = await this.#keys();
|
|
22
|
+
const issuedAt = Math.floor(now.getTime() / 1000);
|
|
23
|
+
const expiresAt = issuedAt + this.#options.accessTtlSeconds;
|
|
24
|
+
const token = await new SignJWT({
|
|
25
|
+
[AUTH_CLAIMS.role]: AUTH_ROLE,
|
|
26
|
+
[AUTH_CLAIMS.sessionId]: claims.sessionId,
|
|
27
|
+
...(claims.email === undefined ? {} : { [AUTH_CLAIMS.email]: claims.email }),
|
|
28
|
+
})
|
|
29
|
+
.setProtectedHeader({ alg: AUTH_ALGORITHM, kid: signer.keyId, typ: "JWT" })
|
|
30
|
+
.setSubject(claims.sub)
|
|
31
|
+
.setIssuer(this.#options.issuer)
|
|
32
|
+
.setAudience(this.#options.audience ?? AUTH_AUDIENCE)
|
|
33
|
+
.setIssuedAt(issuedAt)
|
|
34
|
+
.setExpirationTime(expiresAt)
|
|
35
|
+
.sign(signer.privateKey);
|
|
36
|
+
return { token, expiresAt: new Date(expiresAt * 1000) };
|
|
37
|
+
}
|
|
38
|
+
/** The claims of a token that passed signature (against any key of the set), issuer, audience and expiry, or a refusal. */
|
|
39
|
+
async verify(token, now) {
|
|
40
|
+
const { verifyingSet } = await this.#keys();
|
|
41
|
+
try {
|
|
42
|
+
const { payload } = await jwtVerify(token, verifyingSet, {
|
|
43
|
+
algorithms: [AUTH_ALGORITHM],
|
|
44
|
+
issuer: this.#options.issuer,
|
|
45
|
+
audience: this.#options.audience ?? AUTH_AUDIENCE,
|
|
46
|
+
currentDate: now,
|
|
47
|
+
});
|
|
48
|
+
const sessionId = payload[AUTH_CLAIMS.sessionId];
|
|
49
|
+
if (payload[AUTH_CLAIMS.role] !== AUTH_ROLE || typeof payload.sub !== "string" || typeof sessionId !== "string") {
|
|
50
|
+
return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
|
|
51
|
+
}
|
|
52
|
+
const email = payload[AUTH_CLAIMS.email];
|
|
53
|
+
return ok({ sub: payload.sub, sessionId, ...(typeof email === "string" ? { email } : {}) });
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
return refused(AUTH_ERROR_CODES.AccessTokenInvalid, { cause: String(cause) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The public key document: every key of the set. */
|
|
60
|
+
async jwks() {
|
|
61
|
+
return (await this.#keys()).document;
|
|
62
|
+
}
|
|
63
|
+
/** Signs the state of a Google round trip (an HMAC under a key derived from the signing key). */
|
|
64
|
+
signState(state) {
|
|
65
|
+
return signStateToken(this.#stateKey(), state);
|
|
66
|
+
}
|
|
67
|
+
/** The state when the value is ours, unexpired and untampered; otherwise null. */
|
|
68
|
+
verifyState(value, now) {
|
|
69
|
+
return verifyStateToken(this.#stateKey(), value, now.getTime());
|
|
70
|
+
}
|
|
71
|
+
#stateKey() {
|
|
72
|
+
const signer = this.#options.keys.find((key) => key.privateKey !== undefined);
|
|
73
|
+
if (signer?.privateKey === undefined)
|
|
74
|
+
throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "no signing key" } });
|
|
75
|
+
const pem = pemOf(revealed(signer.privateKey));
|
|
76
|
+
const material = createHash("sha256").update(pem, "utf8").digest();
|
|
77
|
+
return createHmac("sha256", material).update(STATE_LABEL).digest();
|
|
78
|
+
}
|
|
79
|
+
#keys() {
|
|
80
|
+
this.#ring ??= this.#load();
|
|
81
|
+
return this.#ring;
|
|
82
|
+
}
|
|
83
|
+
async #load() {
|
|
84
|
+
const loaded = await Promise.all(this.#options.keys.map((key) => loadKey(key)));
|
|
85
|
+
const signers = loaded.filter((key) => key.privateKey !== undefined);
|
|
86
|
+
const [signer] = signers;
|
|
87
|
+
if (signers.length !== 1 || signer?.privateKey === undefined) {
|
|
88
|
+
throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "the key set needs exactly one signing key", signers: signers.length } });
|
|
89
|
+
}
|
|
90
|
+
const ids = new Set(loaded.map((key) => key.keyId));
|
|
91
|
+
if (ids.size !== loaded.length)
|
|
92
|
+
throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { context: { reason: "two keys share a kid" } });
|
|
93
|
+
const keys = loaded.map((key) => ({ ...key.jwk }));
|
|
94
|
+
return {
|
|
95
|
+
signer: { ...signer, privateKey: signer.privateKey },
|
|
96
|
+
verifyingSet: createLocalJWKSet({ keys }),
|
|
97
|
+
document: { keys: loaded.map((key) => key.jwk) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const publicPemOf = (key) => {
|
|
102
|
+
if (key.publicKey !== undefined)
|
|
103
|
+
return pemOf(key.publicKey);
|
|
104
|
+
if (key.privateKey === undefined)
|
|
105
|
+
throw new Error("a key needs a public key or a private key");
|
|
106
|
+
return createPublicKey(createPrivateKey(pemOf(revealed(key.privateKey)))).export({ type: "spki", format: "pem" }).toString();
|
|
107
|
+
};
|
|
108
|
+
const publicJwkOf = async (publicKey, keyId) => {
|
|
109
|
+
const key = await importSPKI(publicKey, AUTH_ALGORITHM, { extractable: true });
|
|
110
|
+
const exported = await exportJWK(key);
|
|
111
|
+
const kid = keyId ?? (await calculateJwkThumbprint(exported));
|
|
112
|
+
const jwk = {};
|
|
113
|
+
for (const [name, value] of Object.entries({ ...exported, kid, alg: AUTH_ALGORITHM, use: "sig" })) {
|
|
114
|
+
if (typeof value === "string")
|
|
115
|
+
jwk[name] = value;
|
|
116
|
+
}
|
|
117
|
+
return { jwk, keyId: kid };
|
|
118
|
+
};
|
|
119
|
+
const loadKey = async (key) => {
|
|
120
|
+
try {
|
|
121
|
+
const [privateKey, published] = await Promise.all([
|
|
122
|
+
key.privateKey === undefined ? Promise.resolve(undefined) : importPKCS8(pemOf(revealed(key.privateKey)), AUTH_ALGORITHM),
|
|
123
|
+
publicJwkOf(publicPemOf(key), key.keyId),
|
|
124
|
+
]);
|
|
125
|
+
return { keyId: published.keyId, jwk: published.jwk, privateKey };
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
throw new AuthError(AUTH_ERROR_CODES.KeyInvalid, { cause });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Outcome } from "./errors.js";
|
|
2
|
+
import type { AuthClock, AuthPrincipal } from "./ports.js";
|
|
3
|
+
import type { AuthTokens } from "./tokens.js";
|
|
4
|
+
/**
|
|
5
|
+
* What `domain/identity` asks of an identity source: the principal of a bearer token, or a refusal. The lite edition's source is
|
|
6
|
+
* `AuthPrincipalVerifier`; the full edition's is the OIDC verifier of `@starci/auth-oidc` behind this same interface, so guards,
|
|
7
|
+
* `@Public` and `@Credential` do not change between editions.
|
|
8
|
+
*/
|
|
9
|
+
export interface PrincipalVerifier {
|
|
10
|
+
/** Verifies one token at one instant. */
|
|
11
|
+
verify(token: string, now?: Date): Promise<Outcome<AuthPrincipal>>;
|
|
12
|
+
}
|
|
13
|
+
/** The verifier of the app that signs people in: our own access token, checked against our key set, issuer, audience and expiry. */
|
|
14
|
+
export declare class AuthPrincipalVerifier implements PrincipalVerifier {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(tokens: AuthTokens, clock?: AuthClock);
|
|
17
|
+
verify(token: string, now?: Date): Promise<Outcome<AuthPrincipal>>;
|
|
18
|
+
}
|
|
19
|
+
/** The verifier of an app that serves no user session: every access token is refused, so the default-deny guard admits only its public and credential doors. */
|
|
20
|
+
export declare const refuseSessions: PrincipalVerifier;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AUTH_ERROR_CODES, AUTH_ROLE } from "../contracts/index.js";
|
|
2
|
+
import { ok, refused } from "./errors.js";
|
|
3
|
+
import { systemClock } from "./runtime.js";
|
|
4
|
+
/** The verifier of the app that signs people in: our own access token, checked against our key set, issuer, audience and expiry. */
|
|
5
|
+
export class AuthPrincipalVerifier {
|
|
6
|
+
#tokens;
|
|
7
|
+
#clock;
|
|
8
|
+
constructor(tokens, clock = systemClock) {
|
|
9
|
+
this.#tokens = tokens;
|
|
10
|
+
this.#clock = clock;
|
|
11
|
+
}
|
|
12
|
+
async verify(token, now = this.#clock.now()) {
|
|
13
|
+
const verified = await this.#tokens.verify(token, now);
|
|
14
|
+
if (verified.kind === "refused")
|
|
15
|
+
return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
|
|
16
|
+
const { sub, email, sessionId } = verified.value;
|
|
17
|
+
return ok({ id: sub, role: AUTH_ROLE, sessionId, ...(email === undefined ? {} : { email }) });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The verifier of an app that serves no user session: every access token is refused, so the default-deny guard admits only its public and credential doors. */
|
|
21
|
+
export const refuseSessions = {
|
|
22
|
+
verify: () => Promise.resolve(refused(AUTH_ERROR_CODES.AccessTokenInvalid)),
|
|
23
|
+
};
|