@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,72 @@
|
|
|
1
|
+
/** The status of the Google callback redirect. */
|
|
2
|
+
export const SEE_OTHER = 303;
|
|
3
|
+
/** The log event of a failed Google sign-in. */
|
|
4
|
+
export const GOOGLE_SIGN_IN_FAILED_EVENT = "auth.google-sign-in-failed";
|
|
5
|
+
/** The body a sign-in, refresh or session door answers. */
|
|
6
|
+
export const accessOf = (tokens) => ({
|
|
7
|
+
accessToken: tokens.accessToken,
|
|
8
|
+
expiresIn: tokens.accessExpiresIn,
|
|
9
|
+
expiresAt: tokens.accessExpiresAt,
|
|
10
|
+
user: tokens.user,
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* The cookie doors of sign-in over the flows, the sessions and the cookie: what a controller calls. The refresh token only goes
|
|
14
|
+
* into the cookie; a refresh that cannot be used clears it, one a sibling request just rotated leaves it as the sibling wrote it.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthDoors {
|
|
17
|
+
#deps;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.#deps = deps;
|
|
20
|
+
}
|
|
21
|
+
/** Email and password through the local strategy; the refresh token goes into the cookie. */
|
|
22
|
+
async login(exchange) {
|
|
23
|
+
const subject = await this.#deps.flows.authenticateLocal(exchange);
|
|
24
|
+
const tokens = await this.#deps.sessions.start(subject, this.#deps.cookie.deviceOf(exchange.req));
|
|
25
|
+
this.#deps.cookie.issue(exchange.res, tokens);
|
|
26
|
+
return accessOf(tokens);
|
|
27
|
+
}
|
|
28
|
+
/** Rotates the cookie token and answers a new access token; a cookie that cannot be used is cleared and the refusal rethrown. */
|
|
29
|
+
async refresh(exchange) {
|
|
30
|
+
try {
|
|
31
|
+
const tokens = await this.#deps.sessions.refresh(this.#deps.cookie.read(exchange.req));
|
|
32
|
+
if (tokens.cookieKept !== true)
|
|
33
|
+
this.#deps.cookie.issue(exchange.res, tokens);
|
|
34
|
+
return accessOf(tokens);
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
this.#deps.cookie.clear(exchange.res);
|
|
38
|
+
throw cause;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Answers a fresh access token for the cookie session without rotating the cookie. */
|
|
42
|
+
async session(exchange) {
|
|
43
|
+
return accessOf(await this.#deps.sessions.access(this.#deps.cookie.read(exchange.req)));
|
|
44
|
+
}
|
|
45
|
+
/** Ends the session of the cookie and clears it. */
|
|
46
|
+
async logout(exchange) {
|
|
47
|
+
await this.#deps.sessions.end(this.#deps.cookie.read(exchange.req));
|
|
48
|
+
this.#deps.cookie.clear(exchange.res);
|
|
49
|
+
}
|
|
50
|
+
/** Sends the browser to the Google consent screen. */
|
|
51
|
+
beginGoogle(exchange) {
|
|
52
|
+
return this.#deps.flows.beginGoogle(exchange);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Finishes the Google round trip: state, PKCE and profile are verified, the account found, linked or created, its session opened
|
|
56
|
+
* and its cookie set. A failure never throws: the browser is sent back to the sign-in page, because a redirect-only door has
|
|
57
|
+
* nowhere to show an error body.
|
|
58
|
+
*/
|
|
59
|
+
async completeGoogle(exchange) {
|
|
60
|
+
try {
|
|
61
|
+
const completion = await this.#deps.flows.completeGoogle(exchange);
|
|
62
|
+
const tokens = await this.#deps.sessions.start(completion.subject, this.#deps.cookie.deviceOf(exchange.req));
|
|
63
|
+
this.#deps.cookie.issue(exchange.res, tokens);
|
|
64
|
+
return { url: `${this.#deps.returnOrigin}${completion.next}`, statusCode: SEE_OTHER };
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
this.#deps.cookie.clear(exchange.res);
|
|
68
|
+
this.#deps.logger?.warn(GOOGLE_SIGN_IN_FAILED_EVENT, { cause: String(cause) });
|
|
69
|
+
return { url: `${this.#deps.returnOrigin}/sign-in?auth=error`, statusCode: SEE_OTHER };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AuthErrorCode, AuthErrorKind } from "../contracts/index.js";
|
|
2
|
+
/** The one error class of sign-in: a wire code, the kind it travels as and optional context for the log. */
|
|
3
|
+
export declare class AuthError extends Error {
|
|
4
|
+
readonly code: AuthErrorCode;
|
|
5
|
+
readonly kind: AuthErrorKind;
|
|
6
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
7
|
+
constructor(code: AuthErrorCode, options?: {
|
|
8
|
+
readonly cause?: unknown;
|
|
9
|
+
readonly context?: Readonly<Record<string, unknown>>;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** The result of a check that can refuse with a code. */
|
|
13
|
+
export type Outcome<T, E extends string = AuthErrorCode> = {
|
|
14
|
+
readonly kind: "ok";
|
|
15
|
+
readonly value: T;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: "refused";
|
|
18
|
+
readonly code: E;
|
|
19
|
+
readonly detail?: Readonly<Record<string, unknown>>;
|
|
20
|
+
};
|
|
21
|
+
/** An accepted outcome. */
|
|
22
|
+
export declare const ok: <T>(value: T) => Outcome<T, never>;
|
|
23
|
+
/** A refused outcome. */
|
|
24
|
+
export declare const refused: <E extends string>(code: E, detail?: Readonly<Record<string, unknown>>) => Outcome<never, E>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AUTH_ERROR_KINDS } from "../contracts/index.js";
|
|
2
|
+
/** The one error class of sign-in: a wire code, the kind it travels as and optional context for the log. */
|
|
3
|
+
export class AuthError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
kind;
|
|
6
|
+
context;
|
|
7
|
+
constructor(code, options = {}) {
|
|
8
|
+
super(code, options.cause === undefined ? undefined : { cause: options.cause });
|
|
9
|
+
this.name = "AuthError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.kind = AUTH_ERROR_KINDS[code];
|
|
12
|
+
this.context = options.context ?? {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** An accepted outcome. */
|
|
16
|
+
export const ok = (value) => ({ kind: "ok", value });
|
|
17
|
+
/** A refused outcome. */
|
|
18
|
+
export const refused = (code, detail) => detail === undefined ? { kind: "refused", code } : { kind: "refused", code, detail };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HttpExchange } from "./http.js";
|
|
2
|
+
import type { AuthOptions } from "./options.js";
|
|
3
|
+
import type { AuthSubject } from "./ports.js";
|
|
4
|
+
/** The result of the Google callback leg. */
|
|
5
|
+
export interface GoogleCompletion {
|
|
6
|
+
/** The signed-in person. */
|
|
7
|
+
readonly subject: AuthSubject;
|
|
8
|
+
/** The same-site path the browser lands on. */
|
|
9
|
+
readonly next: string;
|
|
10
|
+
}
|
|
11
|
+
/** Runs the passport strategies for one request without sessions; the only caller of `passport.authenticate`. */
|
|
12
|
+
export declare class AuthFlows {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(options: Pick<AuthOptions, "google">);
|
|
15
|
+
/** Runs the local strategy over the posted body; answers the person, or throws the app's refusal. */
|
|
16
|
+
authenticateLocal(exchange: HttpExchange): Promise<AuthSubject>;
|
|
17
|
+
/** Sends the browser to the Google consent screen (the response is the redirect); the optional `next` query value is kept in the state cookie. */
|
|
18
|
+
beginGoogle(exchange: HttpExchange): Promise<void>;
|
|
19
|
+
/** Finishes the Google round trip: the state cookie, the PKCE verifier and the profile must all check out. */
|
|
20
|
+
completeGoogle(exchange: HttpExchange): Promise<GoogleCompletion>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import passport from "passport";
|
|
2
|
+
import { AUTH_ERROR_CODES } from "../contracts/index.js";
|
|
3
|
+
import { AuthError } from "./errors.js";
|
|
4
|
+
import { isRecord, sameSitePath } from "./primitives.js";
|
|
5
|
+
const isSubject = (value) => isRecord(value) && typeof value.userId === "string" && typeof value.email === "string";
|
|
6
|
+
const isHandler = (value) => typeof value === "function";
|
|
7
|
+
/** The middleware passport built; passport types it as any, so it is checked once here. */
|
|
8
|
+
const handlerOf = (built) => {
|
|
9
|
+
if (!isHandler(built))
|
|
10
|
+
throw new AuthError(AUTH_ERROR_CODES.GoogleUnavailable);
|
|
11
|
+
return built;
|
|
12
|
+
};
|
|
13
|
+
const errorOf = (error) => (error instanceof Error ? error : new Error("passport"));
|
|
14
|
+
/** Runs the passport strategies for one request without sessions; the only caller of `passport.authenticate`. */
|
|
15
|
+
export class AuthFlows {
|
|
16
|
+
#google;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.#google = options.google;
|
|
19
|
+
}
|
|
20
|
+
/** Runs the local strategy over the posted body; answers the person, or throws the app's refusal. */
|
|
21
|
+
authenticateLocal(exchange) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const settled = (error, user) => {
|
|
24
|
+
if (error !== null && error !== undefined)
|
|
25
|
+
reject(errorOf(error));
|
|
26
|
+
else if (isSubject(user))
|
|
27
|
+
resolve(user);
|
|
28
|
+
else
|
|
29
|
+
reject(new AuthError(AUTH_ERROR_CODES.MissingCredentials));
|
|
30
|
+
};
|
|
31
|
+
handlerOf(passport.authenticate("local", { session: false }, settled))(exchange.req, exchange.res, reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Sends the browser to the Google consent screen (the response is the redirect); the optional `next` query value is kept in the state cookie. */
|
|
35
|
+
beginGoogle(exchange) {
|
|
36
|
+
if (this.#google === null)
|
|
37
|
+
return Promise.reject(new AuthError(AUTH_ERROR_CODES.GoogleUnavailable));
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
exchange.res.once("finish", resolve);
|
|
40
|
+
const built = passport.authenticate("google", { session: false, prompt: "select_account" }, (error) => {
|
|
41
|
+
reject(errorOf(error));
|
|
42
|
+
});
|
|
43
|
+
handlerOf(built)(exchange.req, exchange.res, reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Finishes the Google round trip: the state cookie, the PKCE verifier and the profile must all check out. */
|
|
47
|
+
completeGoogle(exchange) {
|
|
48
|
+
if (this.#google === null)
|
|
49
|
+
return Promise.reject(new AuthError(AUTH_ERROR_CODES.GoogleUnavailable));
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const settled = (error, user, info) => {
|
|
52
|
+
if (error !== null && error !== undefined) {
|
|
53
|
+
reject(errorOf(error));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!isSubject(user)) {
|
|
57
|
+
reject(new AuthError(AUTH_ERROR_CODES.GoogleStateInvalid));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const state = isRecord(info) && isRecord(info.state) ? info.state.next : undefined;
|
|
61
|
+
resolve({ subject: user, next: sameSitePath(state, "/") });
|
|
62
|
+
};
|
|
63
|
+
handlerOf(passport.authenticate("google", { session: false }, settled))(exchange.req, exchange.res, reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HttpRequest } from "./http.js";
|
|
2
|
+
import type { AuthOptions } from "./options.js";
|
|
3
|
+
import type { AuthClock, AuthIds } from "./ports.js";
|
|
4
|
+
import type { AuthTokens } from "./tokens.js";
|
|
5
|
+
/** The cookie that carries one Google round trip, when the options name none. */
|
|
6
|
+
export declare const DEFAULT_GOOGLE_STATE_COOKIE = "auth_google_state";
|
|
7
|
+
/** The only path the state cookie is sent to. */
|
|
8
|
+
export declare const GOOGLE_STATE_PATH: "/auth/google";
|
|
9
|
+
/** How long a Google round trip may take, in milliseconds. */
|
|
10
|
+
export declare const GOOGLE_STATE_TTL_MS = 600000;
|
|
11
|
+
type StoreDone = (error: Error | null, handle?: string) => void;
|
|
12
|
+
type VerifyDone = (error: Error | null, verifier?: unknown, context?: unknown) => void;
|
|
13
|
+
/**
|
|
14
|
+
* The state store passport-oauth2 calls for `state` and PKCE without a server session: the handle (echoed as `state`), the code
|
|
15
|
+
* verifier and the landing path travel in a signed, httpOnly cookie that only the callback path receives, and the handle is
|
|
16
|
+
* compared on return. The landing path is a same-site path only, so the callback can never redirect off the web origin.
|
|
17
|
+
*/
|
|
18
|
+
export declare class GoogleStateClient {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(deps: {
|
|
21
|
+
readonly tokens: AuthTokens;
|
|
22
|
+
readonly clock: AuthClock;
|
|
23
|
+
readonly ids: AuthIds;
|
|
24
|
+
readonly options: Pick<AuthOptions, "returnOrigin" | "google">;
|
|
25
|
+
});
|
|
26
|
+
/** The name of the state cookie. */
|
|
27
|
+
get cookieName(): string;
|
|
28
|
+
/** Called by passport-oauth2 on the outbound leg (the five-argument PKCE form). */
|
|
29
|
+
store(req: HttpRequest, verifier: string, _state: unknown, _meta: unknown, done: StoreDone): void;
|
|
30
|
+
/** Called by passport-oauth2 on the return leg (the meta form, since this method takes four arguments): the cookie must be ours, unexpired and carry the same handle as `state`. */
|
|
31
|
+
verify(req: HttpRequest, providedState: string, _meta: unknown, done: VerifyDone): void;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { AUTH_DOORS } from "../contracts/index.js";
|
|
2
|
+
import { cookieValue, sameSitePath, sameValue } from "./primitives.js";
|
|
3
|
+
/** The cookie that carries one Google round trip, when the options name none. */
|
|
4
|
+
export const DEFAULT_GOOGLE_STATE_COOKIE = "auth_google_state";
|
|
5
|
+
/** The only path the state cookie is sent to. */
|
|
6
|
+
export const GOOGLE_STATE_PATH = AUTH_DOORS.google;
|
|
7
|
+
/** How long a Google round trip may take, in milliseconds. */
|
|
8
|
+
export const GOOGLE_STATE_TTL_MS = 600_000;
|
|
9
|
+
/**
|
|
10
|
+
* The state store passport-oauth2 calls for `state` and PKCE without a server session: the handle (echoed as `state`), the code
|
|
11
|
+
* verifier and the landing path travel in a signed, httpOnly cookie that only the callback path receives, and the handle is
|
|
12
|
+
* compared on return. The landing path is a same-site path only, so the callback can never redirect off the web origin.
|
|
13
|
+
*/
|
|
14
|
+
export class GoogleStateClient {
|
|
15
|
+
#tokens;
|
|
16
|
+
#clock;
|
|
17
|
+
#ids;
|
|
18
|
+
#returnOrigin;
|
|
19
|
+
#cookie;
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
this.#tokens = deps.tokens;
|
|
22
|
+
this.#clock = deps.clock;
|
|
23
|
+
this.#ids = deps.ids;
|
|
24
|
+
this.#returnOrigin = deps.options.returnOrigin;
|
|
25
|
+
this.#cookie = deps.options.google?.stateCookie ?? DEFAULT_GOOGLE_STATE_COOKIE;
|
|
26
|
+
}
|
|
27
|
+
/** The name of the state cookie. */
|
|
28
|
+
get cookieName() {
|
|
29
|
+
return this.#cookie;
|
|
30
|
+
}
|
|
31
|
+
/** Called by passport-oauth2 on the outbound leg (the five-argument PKCE form). */
|
|
32
|
+
store(req, verifier, _state, _meta, done) {
|
|
33
|
+
const handle = this.#ids.next();
|
|
34
|
+
const signed = this.#tokens.signState({
|
|
35
|
+
w: handle,
|
|
36
|
+
u: verifier,
|
|
37
|
+
r: this.#landingOf(req),
|
|
38
|
+
e: this.#clock.now().getTime() + GOOGLE_STATE_TTL_MS,
|
|
39
|
+
p: "google",
|
|
40
|
+
});
|
|
41
|
+
req.res?.cookie(this.#cookie, signed, {
|
|
42
|
+
httpOnly: true,
|
|
43
|
+
secure: true,
|
|
44
|
+
sameSite: "lax",
|
|
45
|
+
path: GOOGLE_STATE_PATH,
|
|
46
|
+
maxAge: GOOGLE_STATE_TTL_MS,
|
|
47
|
+
});
|
|
48
|
+
done(null, handle);
|
|
49
|
+
}
|
|
50
|
+
/** The same-site path to land on: the requested return address when it is a path, or an absolute address on the web origin; else the root. */
|
|
51
|
+
#landingOf(req) {
|
|
52
|
+
const { returnTo, next } = req.query ?? {};
|
|
53
|
+
const wanted = typeof returnTo === "string" ? returnTo : next;
|
|
54
|
+
if (typeof wanted !== "string")
|
|
55
|
+
return "/";
|
|
56
|
+
if (!URL.canParse(wanted))
|
|
57
|
+
return sameSitePath(wanted, "/");
|
|
58
|
+
const url = new URL(wanted);
|
|
59
|
+
return url.origin === this.#returnOrigin ? sameSitePath(`${url.pathname}${url.search}`, "/") : "/";
|
|
60
|
+
}
|
|
61
|
+
/** Called by passport-oauth2 on the return leg (the meta form, since this method takes four arguments): the cookie must be ours, unexpired and carry the same handle as `state`. */
|
|
62
|
+
verify(req, providedState, _meta, done) {
|
|
63
|
+
const header = req.headers.cookie;
|
|
64
|
+
const raw = cookieValue(Array.isArray(header) ? header.join("; ") : (header ?? ""), this.#cookie);
|
|
65
|
+
req.res?.clearCookie(this.#cookie, { httpOnly: true, secure: true, sameSite: "lax", path: GOOGLE_STATE_PATH });
|
|
66
|
+
const state = raw === "" ? null : this.#tokens.verifyState(raw, this.#clock.now());
|
|
67
|
+
if (state?.p !== "google" || typeof providedState !== "string" || !sameValue(state.w, providedState)) {
|
|
68
|
+
done(null, false, { message: "state" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
done(null, state.u, { next: sameSitePath(state.r, "/") });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The cookie attributes a response writes. */
|
|
2
|
+
export interface CookieWrite {
|
|
3
|
+
readonly httpOnly: boolean;
|
|
4
|
+
readonly secure: boolean;
|
|
5
|
+
readonly sameSite: "lax" | "strict";
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly maxAge?: number;
|
|
8
|
+
readonly domain?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The part of a request the package reads. */
|
|
11
|
+
export interface HttpRequest {
|
|
12
|
+
readonly headers: Readonly<Record<string, string | string[] | undefined>>;
|
|
13
|
+
readonly ip?: string | undefined;
|
|
14
|
+
readonly query?: Readonly<Record<string, unknown>> | undefined;
|
|
15
|
+
readonly res?: HttpResponse | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The part of a response the package writes. */
|
|
18
|
+
export interface HttpResponse {
|
|
19
|
+
cookie(name: string, value: string, options: CookieWrite): unknown;
|
|
20
|
+
clearCookie(name: string, options: Omit<CookieWrite, "maxAge">): unknown;
|
|
21
|
+
once(event: "finish", listener: () => void): unknown;
|
|
22
|
+
}
|
|
23
|
+
/** The two halves of an HTTP exchange the flows work on (an Express request and response satisfy it). */
|
|
24
|
+
export interface HttpExchange {
|
|
25
|
+
readonly req: HttpRequest;
|
|
26
|
+
readonly res: HttpResponse;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { AUTH_AUTHENTICATOR, AUTH_DOORS_SERVICE, AUTH_FLOWS, AUTH_GOOGLE_STATE, AUTH_GOOGLE_STRATEGY, AUTH_LOCAL_STRATEGY, AUTH_PASSWORDS, AUTH_PRINCIPAL_VERIFIER, AUTH_REFRESH_COOKIE, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET, AUTH_TOKENS } from "./auth.tokens.js";
|
|
2
|
+
export { AuthModule, MODULE_OPTIONS_TOKEN as AUTH_OPTIONS } from "./auth.module.js";
|
|
3
|
+
export type { AuthModuleExtras, AuthRegisterAsyncOptions, AuthRegisterOptions } from "./auth.module.js";
|
|
4
|
+
export { SignInBudget } from "./budget.js";
|
|
5
|
+
export { RefreshCookie } from "./cookie.js";
|
|
6
|
+
export { AuthDoors, GOOGLE_SIGN_IN_FAILED_EVENT, SEE_OTHER, accessOf } from "./doors.js";
|
|
7
|
+
export type { DoorsDeps } from "./doors.js";
|
|
8
|
+
export { AuthError, ok, refused } from "./errors.js";
|
|
9
|
+
export type { Outcome } from "./errors.js";
|
|
10
|
+
export { AuthFlows } from "./flows.js";
|
|
11
|
+
export type { GoogleCompletion } from "./flows.js";
|
|
12
|
+
export { DEFAULT_GOOGLE_STATE_COOKIE, GOOGLE_STATE_PATH, GOOGLE_STATE_TTL_MS, GoogleStateClient } from "./google-state.js";
|
|
13
|
+
export type { CookieWrite, HttpExchange, HttpRequest, HttpResponse } from "./http.js";
|
|
14
|
+
export type { AuthOptions, BudgetOptions, GoogleOptions, RefreshCookieOptions, SigningKeyOptions } from "./options.js";
|
|
15
|
+
export { AuthPasswords } from "./passwords.js";
|
|
16
|
+
export type { AccessClaims, AccountDirectory, AccountStanding, AuthAuthenticator, AuthClock, AuthIds, AuthLogger, AuthPrincipal, AuthSubject, DeviceMeta, GoogleProfile, IssuedAccessToken, NewSession, RateCounter, RefreshTokenRecord, SessionStore, SessionTokens, SessionTransaction, } from "./ports.js";
|
|
17
|
+
export { cookieValue, digestOf, isAcceptablePassword, isPresentablePassword, normalizeEmail, sameSitePath } from "./primitives.js";
|
|
18
|
+
export type { Revealable, SecretInput } from "./primitives.js";
|
|
19
|
+
export { randomIds, systemClock } from "./runtime.js";
|
|
20
|
+
export { AuthSessions, DEFAULT_REUSE_GRACE_MS, REFRESH_REUSE_EVENT } from "./sessions.js";
|
|
21
|
+
export { googleProfileOf } from "./strategies.js";
|
|
22
|
+
export { AuthTokens } from "./tokens.js";
|
|
23
|
+
export { AuthPrincipalVerifier, refuseSessions } from "./verifier.js";
|
|
24
|
+
export type { PrincipalVerifier } from "./verifier.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { AUTH_AUTHENTICATOR, AUTH_DOORS_SERVICE, AUTH_FLOWS, AUTH_GOOGLE_STATE, AUTH_GOOGLE_STRATEGY, AUTH_LOCAL_STRATEGY, AUTH_PASSWORDS, AUTH_PRINCIPAL_VERIFIER, AUTH_REFRESH_COOKIE, AUTH_SESSIONS, AUTH_SIGN_IN_BUDGET, AUTH_TOKENS } from "./auth.tokens.js";
|
|
2
|
+
export { AuthModule, MODULE_OPTIONS_TOKEN as AUTH_OPTIONS } from "./auth.module.js";
|
|
3
|
+
export { SignInBudget } from "./budget.js";
|
|
4
|
+
export { RefreshCookie } from "./cookie.js";
|
|
5
|
+
export { AuthDoors, GOOGLE_SIGN_IN_FAILED_EVENT, SEE_OTHER, accessOf } from "./doors.js";
|
|
6
|
+
export { AuthError, ok, refused } from "./errors.js";
|
|
7
|
+
export { AuthFlows } from "./flows.js";
|
|
8
|
+
export { DEFAULT_GOOGLE_STATE_COOKIE, GOOGLE_STATE_PATH, GOOGLE_STATE_TTL_MS, GoogleStateClient } from "./google-state.js";
|
|
9
|
+
export { AuthPasswords } from "./passwords.js";
|
|
10
|
+
export { cookieValue, digestOf, isAcceptablePassword, isPresentablePassword, normalizeEmail, sameSitePath } from "./primitives.js";
|
|
11
|
+
export { randomIds, systemClock } from "./runtime.js";
|
|
12
|
+
export { AuthSessions, DEFAULT_REUSE_GRACE_MS, REFRESH_REUSE_EVENT } from "./sessions.js";
|
|
13
|
+
export { googleProfileOf } from "./strategies.js";
|
|
14
|
+
export { AuthTokens } from "./tokens.js";
|
|
15
|
+
export { AuthPrincipalVerifier, refuseSessions } from "./verifier.js";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SecretInput } from "./primitives.js";
|
|
2
|
+
import type { AccountDirectory, AuthClock, AuthIds, AuthLogger, RateCounter, SessionStore } from "./ports.js";
|
|
3
|
+
/** One signing key of the key set. */
|
|
4
|
+
export interface SigningKeyOptions {
|
|
5
|
+
/** The public key (SPKI PEM) published at the JWKS door. Derived from the private key when absent; a verify-only key needs it. */
|
|
6
|
+
readonly publicKey?: string | undefined;
|
|
7
|
+
/** The Ed25519 private key (PKCS#8 PEM). Exactly one key of the set carries it: the active signer. A key without it only verifies. */
|
|
8
|
+
readonly privateKey?: SecretInput | undefined;
|
|
9
|
+
/** The `kid` of the key; the RFC 7638 thumbprint of the public key when absent. */
|
|
10
|
+
readonly keyId?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** The Google OAuth client and where Google sends the browser back. */
|
|
13
|
+
export interface GoogleOptions {
|
|
14
|
+
/** The public client id. */
|
|
15
|
+
readonly clientId: string;
|
|
16
|
+
/** The client secret. */
|
|
17
|
+
readonly clientSecret: SecretInput;
|
|
18
|
+
/** The registered callback address (`<api>/auth/google/callback`). */
|
|
19
|
+
readonly callbackUrl: string;
|
|
20
|
+
/** The name of the cookie that carries one Google round trip. */
|
|
21
|
+
readonly stateCookie?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
/** The refresh cookie. */
|
|
24
|
+
export interface RefreshCookieOptions {
|
|
25
|
+
/** The cookie name. */
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/** The `Domain` of the cookie; undefined keeps it host-only. */
|
|
28
|
+
readonly domain?: string | undefined;
|
|
29
|
+
/** The path the cookie is sent on (the web app's server reads it on its own pages and forwards it to the api). */
|
|
30
|
+
readonly path?: string | undefined;
|
|
31
|
+
/** The paths older sessions wrote the cookie under; each is cleared so a stale copy never shadows the live one. */
|
|
32
|
+
readonly legacyPaths?: readonly string[] | undefined;
|
|
33
|
+
/** `lax` (default) or `strict`. */
|
|
34
|
+
readonly sameSite?: "lax" | "strict" | undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The budget of sign-in failures. */
|
|
37
|
+
export interface BudgetOptions {
|
|
38
|
+
/** Sign-in failures per address in one window. */
|
|
39
|
+
readonly loginAttempts: number;
|
|
40
|
+
/** Reset and signup requests per address in one window. */
|
|
41
|
+
readonly resetAttempts: number;
|
|
42
|
+
/** The window, in milliseconds. */
|
|
43
|
+
readonly windowMs: number;
|
|
44
|
+
}
|
|
45
|
+
/** Everything the package needs, typed and parsed by the app (the package reads no environment). */
|
|
46
|
+
export interface AuthOptions {
|
|
47
|
+
/** The `iss` claim. */
|
|
48
|
+
readonly issuer: string;
|
|
49
|
+
/** The `aud` claim; `authenticated` when absent. */
|
|
50
|
+
readonly audience?: string | undefined;
|
|
51
|
+
/** The signing keys: the first with a private key signs, every key verifies and is published, so a retired key stays valid until its tokens expire. */
|
|
52
|
+
readonly keys: readonly SigningKeyOptions[];
|
|
53
|
+
/** How long an access token lives, in seconds. */
|
|
54
|
+
readonly accessTtlSeconds: number;
|
|
55
|
+
/** How long a refresh token lives, in seconds. */
|
|
56
|
+
readonly refreshTtlSeconds: number;
|
|
57
|
+
/** A rotated refresh token presented again within this many milliseconds is a racing tab, not a theft (30 seconds when absent). */
|
|
58
|
+
readonly reuseGraceMs?: number | undefined;
|
|
59
|
+
/** The bcrypt cost of a new password hash. */
|
|
60
|
+
readonly bcryptCost: number;
|
|
61
|
+
/** The origin of the web app: a return address is accepted only on this origin, and the Google callback lands there. */
|
|
62
|
+
readonly returnOrigin: string;
|
|
63
|
+
/** The Google client; null when the deployment has none (the Google door is then unavailable). */
|
|
64
|
+
readonly google: GoogleOptions | null;
|
|
65
|
+
/** The refresh cookie. */
|
|
66
|
+
readonly cookie: RefreshCookieOptions;
|
|
67
|
+
/** The sign-in budget. */
|
|
68
|
+
readonly budget: BudgetOptions;
|
|
69
|
+
/** The session tables behind the app's ORM. */
|
|
70
|
+
readonly sessions: SessionStore;
|
|
71
|
+
/** The app's user table, as far as sessions need it. */
|
|
72
|
+
readonly accounts: AccountDirectory;
|
|
73
|
+
/** The counter the budget uses. */
|
|
74
|
+
readonly rate: RateCounter;
|
|
75
|
+
/** The clock (system time when absent). */
|
|
76
|
+
readonly clock?: AuthClock | undefined;
|
|
77
|
+
/** The id source (random UUIDs when absent). */
|
|
78
|
+
readonly ids?: AuthIds | undefined;
|
|
79
|
+
/** The security-event log (silent when absent). */
|
|
80
|
+
readonly logger?: AuthLogger | undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The one owner of bcrypt: hashes new passwords and verifies hashes, including any written by another bcrypt implementation. */
|
|
2
|
+
export declare class AuthPasswords {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(options: {
|
|
5
|
+
readonly bcryptCost: number;
|
|
6
|
+
});
|
|
7
|
+
/** A bcrypt hash of a new password. */
|
|
8
|
+
hash(plain: string): Promise<string>;
|
|
9
|
+
/** True when the password matches the stored hash (constant time); an empty stored hash is compared against a decoy and never matches. */
|
|
10
|
+
verify(plain: string, stored: string): Promise<boolean>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
/** A valid bcrypt hash nobody can match, compared when no account exists so a missing account costs the same time as a wrong password. */
|
|
3
|
+
const DECOY_HASH = "$2b$10$6RdlN0g82RrrsKDj5Hb7pu4B48ZqLbJd7F245q9CktC/LrHJyag9.";
|
|
4
|
+
/** The one owner of bcrypt: hashes new passwords and verifies hashes, including any written by another bcrypt implementation. */
|
|
5
|
+
export class AuthPasswords {
|
|
6
|
+
#cost;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.#cost = options.bcryptCost;
|
|
9
|
+
}
|
|
10
|
+
/** A bcrypt hash of a new password. */
|
|
11
|
+
hash(plain) {
|
|
12
|
+
return bcrypt.hash(plain, this.#cost);
|
|
13
|
+
}
|
|
14
|
+
/** True when the password matches the stored hash (constant time); an empty stored hash is compared against a decoy and never matches. */
|
|
15
|
+
async verify(plain, stored) {
|
|
16
|
+
if (stored === "") {
|
|
17
|
+
await bcrypt.compare(plain, DECOY_HASH);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return bcrypt.compare(plain, stored);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AttemptKind, SessionUser } from "../contracts/index.js";
|
|
2
|
+
/** The person a sign-in method established; the session is opened for them. */
|
|
3
|
+
export interface AuthSubject {
|
|
4
|
+
/** The account id. */
|
|
5
|
+
readonly userId: string;
|
|
6
|
+
/** The account email. */
|
|
7
|
+
readonly email: string;
|
|
8
|
+
}
|
|
9
|
+
/** What Google told us about the person who consented. */
|
|
10
|
+
export interface GoogleProfile {
|
|
11
|
+
/** Google's stable account id (`sub`). */
|
|
12
|
+
readonly subject: string;
|
|
13
|
+
/** The Google email, when released. */
|
|
14
|
+
readonly email: string | null;
|
|
15
|
+
/** True when Google verified that mailbox. */
|
|
16
|
+
readonly emailVerified: boolean;
|
|
17
|
+
/** The display name, when released. */
|
|
18
|
+
readonly name: string | null;
|
|
19
|
+
/** The picture address, when released. */
|
|
20
|
+
readonly picture: string | null;
|
|
21
|
+
}
|
|
22
|
+
/** The sign-in decisions the app makes for the two strategies; each throws the app's error when it refuses. */
|
|
23
|
+
export interface AuthAuthenticator {
|
|
24
|
+
/** Verifies an email and password; answers the person or throws. */
|
|
25
|
+
verifyPassword(email: string, password: string): Promise<AuthSubject>;
|
|
26
|
+
/** Finds, links or creates the person a verified Google profile stands for; answers the person or throws. */
|
|
27
|
+
signInWithGoogle(profile: GoogleProfile): Promise<AuthSubject>;
|
|
28
|
+
}
|
|
29
|
+
/** The account facts a refresh needs to decide that a session may go on. */
|
|
30
|
+
export interface AccountStanding {
|
|
31
|
+
/** The account id. */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** The address, or null. */
|
|
34
|
+
readonly email: string | null;
|
|
35
|
+
/** Whether the account is disabled or gone. */
|
|
36
|
+
readonly disabled: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** The app's user table, seen only as far as sessions need it. */
|
|
39
|
+
export interface AccountDirectory {
|
|
40
|
+
/** The standing of one account, or undefined when it does not exist. */
|
|
41
|
+
find(userId: string): Promise<AccountStanding | undefined>;
|
|
42
|
+
}
|
|
43
|
+
/** One refresh token as the store keeps it: only its digest, and a spent token is kept for replay detection. */
|
|
44
|
+
export interface RefreshTokenRecord {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly sessionId: string;
|
|
47
|
+
readonly userId: string;
|
|
48
|
+
readonly tokenHash: string;
|
|
49
|
+
readonly expiresAt: Date;
|
|
50
|
+
/** When the token was rotated away, or null while it is the live one. */
|
|
51
|
+
readonly usedAt: Date | null;
|
|
52
|
+
}
|
|
53
|
+
/** A device session to open. */
|
|
54
|
+
export interface NewSession {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly userId: string;
|
|
57
|
+
readonly now: Date;
|
|
58
|
+
readonly userAgent: string | null;
|
|
59
|
+
readonly ip: string | null;
|
|
60
|
+
}
|
|
61
|
+
/** The writes of one rotation, applied atomically by the store. */
|
|
62
|
+
export interface SessionTransaction {
|
|
63
|
+
/** Opens a device session. */
|
|
64
|
+
openSession(session: NewSession): Promise<void>;
|
|
65
|
+
/** Stores a live refresh token. */
|
|
66
|
+
insertToken(token: Omit<RefreshTokenRecord, "usedAt">): Promise<void>;
|
|
67
|
+
/** The token with this digest, locked for update (a pessimistic write lock), or null. */
|
|
68
|
+
findTokenForUpdate(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
|
69
|
+
/** Marks a token as rotated away at `at`. */
|
|
70
|
+
markTokenUsed(tokenId: string, at: Date): Promise<void>;
|
|
71
|
+
/** Deletes the tokens of an account that expired before `before`. */
|
|
72
|
+
deleteExpiredTokens(userId: string, before: Date): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
/** The tables the package owns (`identity_sessions`, `identity_refresh_tokens`), behind the app's ORM. */
|
|
75
|
+
export interface SessionStore {
|
|
76
|
+
/** Runs `work` in one database transaction. */
|
|
77
|
+
transaction<T>(work: (tx: SessionTransaction) => Promise<T>): Promise<T>;
|
|
78
|
+
/** The token with this digest, or null. */
|
|
79
|
+
findToken(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
|
80
|
+
/** Marks a session refreshed. */
|
|
81
|
+
touchSession(sessionId: string, now: Date): Promise<void>;
|
|
82
|
+
/** Ends one session (and with it every token of it). */
|
|
83
|
+
closeSession(sessionId: string): Promise<void>;
|
|
84
|
+
/** Ends every session of an account but one. */
|
|
85
|
+
closeOtherSessions(userId: string, keepSessionId: string): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
/** A counter every replica shares (the app's rate-limit store). */
|
|
88
|
+
export interface RateCounter {
|
|
89
|
+
/** Counts one hit in the window; false when the key is over `limit`. */
|
|
90
|
+
consume(key: string, windowMs: number, limit: number): Promise<boolean>;
|
|
91
|
+
/** Forgets a key. */
|
|
92
|
+
clear(key: string): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
/** The clock the package reads. */
|
|
95
|
+
export interface AuthClock {
|
|
96
|
+
now(): Date;
|
|
97
|
+
}
|
|
98
|
+
/** The source of ids the package mints. */
|
|
99
|
+
export interface AuthIds {
|
|
100
|
+
next(): string;
|
|
101
|
+
}
|
|
102
|
+
/** Where the package reports a security event (a replayed refresh token). */
|
|
103
|
+
export interface AuthLogger {
|
|
104
|
+
warn(event: string, context?: Readonly<Record<string, unknown>>): void;
|
|
105
|
+
}
|
|
106
|
+
/** The device a request comes from. */
|
|
107
|
+
export interface DeviceMeta {
|
|
108
|
+
readonly userAgent: string | null;
|
|
109
|
+
readonly ip: string | null;
|
|
110
|
+
}
|
|
111
|
+
/** The tokens one sign-in or refresh produces; the refresh token only ever leaves in the cookie. */
|
|
112
|
+
export interface SessionTokens {
|
|
113
|
+
readonly accessToken: string;
|
|
114
|
+
readonly accessExpiresAt: string;
|
|
115
|
+
readonly accessExpiresIn: number;
|
|
116
|
+
/** The opaque token the refresh cookie carries. */
|
|
117
|
+
readonly cookieToken: string;
|
|
118
|
+
/** True when the answer did not rotate the token (a racing request, or the non-rotating session door): the caller leaves the cookie as it is. */
|
|
119
|
+
readonly cookieKept?: true;
|
|
120
|
+
readonly refreshMaxAgeSeconds: number;
|
|
121
|
+
readonly user: SessionUser;
|
|
122
|
+
}
|
|
123
|
+
/** The claims of one of our access tokens after verification. */
|
|
124
|
+
export interface AccessClaims {
|
|
125
|
+
/** The user id. */
|
|
126
|
+
readonly sub: string;
|
|
127
|
+
/** The email at issue time, when the account has one. */
|
|
128
|
+
readonly email?: string | undefined;
|
|
129
|
+
/** The device session the token belongs to. */
|
|
130
|
+
readonly sessionId: string;
|
|
131
|
+
}
|
|
132
|
+
/** The principal domain code builds from a verified access token. */
|
|
133
|
+
export interface AuthPrincipal {
|
|
134
|
+
readonly id: string;
|
|
135
|
+
readonly role: "authenticated";
|
|
136
|
+
readonly sessionId: string;
|
|
137
|
+
readonly email?: string;
|
|
138
|
+
}
|
|
139
|
+
/** A freshly signed access token. */
|
|
140
|
+
export interface IssuedAccessToken {
|
|
141
|
+
readonly token: string;
|
|
142
|
+
readonly expiresAt: Date;
|
|
143
|
+
}
|
|
144
|
+
/** The kinds of attempt a budget counts. */
|
|
145
|
+
export type { AttemptKind };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|