@starci/auth 0.3.0 → 0.5.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 +11 -0
- package/README.md +2 -0
- package/dist/server/cookie.d.ts +5 -4
- package/dist/server/cookie.js +18 -9
- package/dist/server/doors.js +6 -6
- package/dist/server/errors.d.ts +2 -2
- package/dist/server/errors.js +3 -5
- package/dist/server/flows.js +4 -3
- package/dist/server/options.d.ts +6 -1
- package/dist/server/primitives.d.ts +0 -2
- package/dist/server/primitives.js +2 -3
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-11-08
|
|
4
|
+
|
|
5
|
+
- Changed: `AuthError` extends `DomainError<AuthErrorCode>` of `@starci/common` (new dependency); its name, codes, constructor, `kind`, `context` and `cause` are unchanged, and it gains `params` (always empty).
|
|
6
|
+
- Changed: the package's own non-array object guard is `isPlainRecord` of `@starci/common`.
|
|
7
|
+
- Changed: requires `@starci/common` 1.0.1 (exact), the one copy an app installs, so `DomainError` stays one class (this version had not reached npm).
|
|
8
|
+
|
|
9
|
+
## 0.4.0 - 2026-11-04
|
|
10
|
+
|
|
11
|
+
- Added: `RefreshCookieOptions.domains`: the refresh cookie's `Domain` follows the request host (its `Host` header without the port). A host equal to a listed domain, or ending in `.` and one, takes the longest that fits; any other host takes `domain`, else stays host-only, so one api signs in on localhost (host-only) and on a tunnel origin (its parent domain).
|
|
12
|
+
- Changed: `RefreshCookie.issue(response, tokens, request)` and `clear(response, request)` take the request; `AuthDoors` passes it at every write and clear. Specs: `src/server/cookie.spec.ts` (localhost host-only, a tunnel host under a listed domain, the longest fit, an unlisted host and no host fall back).
|
|
13
|
+
|
|
3
14
|
## 0.3.0 - 2026-11-03
|
|
4
15
|
|
|
5
16
|
- Added: a session carries the person's full name when the account directory knows it: `AccountStanding.name` and `AuthSubject.name` in; `SessionUser.name`, the `name` access-token claim (`AUTH_CLAIMS.name`), `AccessClaims.name` and `AuthPrincipal.name` out. The client keeps it (from the grant, or from the token when the grant carries no user) and announces a change of it. An account without a name answers as before.
|
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ The sign-in of a lite StarCi app: one package, four entries.
|
|
|
12
12
|
|
|
13
13
|
An app reaches the package only through `integrations/passport` (back end) and `modules/auth/client.ts` (front end); the lint refuses `passport*`, `jose`, `bcryptjs` and `@nestjs/jwt` in an app.
|
|
14
14
|
|
|
15
|
+
The server throws one error class, `AuthError`: a `DomainError` of `@starci/common` whose `code` is a wire code, with the `kind` it travels as and a non-secret `context`.
|
|
16
|
+
|
|
15
17
|
## Back end
|
|
16
18
|
|
|
17
19
|
```ts
|
package/dist/server/cookie.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { HttpRequest, HttpResponse } from "./http.js";
|
|
|
2
2
|
import type { RefreshCookieOptions } from "./options.js";
|
|
3
3
|
import type { DeviceMeta, SessionTokens } from "./ports.js";
|
|
4
4
|
/**
|
|
5
|
-
* The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the
|
|
6
|
-
* app and the API both see it. The token only ever leaves the back
|
|
5
|
+
* The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the domain the request host falls
|
|
6
|
+
* under (one of `domains`, else `domain`, else host-only) so the web app and the API both see it. The token only ever leaves the back
|
|
7
|
+
* end through this cookie, never in a body or a URL.
|
|
7
8
|
*/
|
|
8
9
|
export declare class RefreshCookie {
|
|
9
10
|
#private;
|
|
@@ -13,7 +14,7 @@ export declare class RefreshCookie {
|
|
|
13
14
|
/** The device a request comes from: its user agent (bounded) and caller address. */
|
|
14
15
|
deviceOf(request: HttpRequest): DeviceMeta;
|
|
15
16
|
/** Sets the cookie to the refresh token of a session, clearing the copies older sessions kept under their paths. */
|
|
16
|
-
issue(response: HttpResponse, tokens: SessionTokens): void;
|
|
17
|
+
issue(response: HttpResponse, tokens: SessionTokens, request: HttpRequest): void;
|
|
17
18
|
/** Clears the cookie on its path and on the paths older sessions used. */
|
|
18
|
-
clear(response: HttpResponse): void;
|
|
19
|
+
clear(response: HttpResponse, request: HttpRequest): void;
|
|
19
20
|
}
|
package/dist/server/cookie.js
CHANGED
|
@@ -2,8 +2,9 @@ import { cookieValue } from "./primitives.js";
|
|
|
2
2
|
const MAX_AGENT_LENGTH = 512;
|
|
3
3
|
const MILLISECONDS = 1000;
|
|
4
4
|
/**
|
|
5
|
-
* The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the
|
|
6
|
-
* app and the API both see it. The token only ever leaves the back
|
|
5
|
+
* The refresh cookie: httpOnly and Secure on every write and clear, SameSite lax (or strict), on the domain the request host falls
|
|
6
|
+
* under (one of `domains`, else `domain`, else host-only) so the web app and the API both see it. The token only ever leaves the back
|
|
7
|
+
* end through this cookie, never in a body or a URL.
|
|
7
8
|
*/
|
|
8
9
|
export class RefreshCookie {
|
|
9
10
|
#options;
|
|
@@ -22,18 +23,27 @@ export class RefreshCookie {
|
|
|
22
23
|
return { userAgent: text?.slice(0, MAX_AGENT_LENGTH) ?? null, ip: request.ip ?? null };
|
|
23
24
|
}
|
|
24
25
|
/** Sets the cookie to the refresh token of a session, clearing the copies older sessions kept under their paths. */
|
|
25
|
-
issue(response, tokens) {
|
|
26
|
+
issue(response, tokens, request) {
|
|
27
|
+
const domain = this.#domainOf(request);
|
|
26
28
|
for (const path of this.#legacyPaths())
|
|
27
|
-
response.clearCookie(this.#options.name, this.#flags(path));
|
|
29
|
+
response.clearCookie(this.#options.name, this.#flags(path, domain));
|
|
28
30
|
response.cookie(this.#options.name, tokens.cookieToken, {
|
|
29
|
-
...this.#flags(this.#path()),
|
|
31
|
+
...this.#flags(this.#path(), domain),
|
|
30
32
|
maxAge: tokens.refreshMaxAgeSeconds * MILLISECONDS,
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
35
|
/** Clears the cookie on its path and on the paths older sessions used. */
|
|
34
|
-
clear(response) {
|
|
36
|
+
clear(response, request) {
|
|
37
|
+
const domain = this.#domainOf(request);
|
|
35
38
|
for (const path of [this.#path(), ...this.#legacyPaths()])
|
|
36
|
-
response.clearCookie(this.#options.name, this.#flags(path));
|
|
39
|
+
response.clearCookie(this.#options.name, this.#flags(path, domain));
|
|
40
|
+
}
|
|
41
|
+
/** The `Domain` for a request: the longest listed domain its host (without the port) equals or ends under, else the configured one. */
|
|
42
|
+
#domainOf(request) {
|
|
43
|
+
const header = request.headers.host;
|
|
44
|
+
const host = (Array.isArray(header) ? header[0] : header)?.replace(/:\d+$/u, "").toLowerCase() ?? "";
|
|
45
|
+
const fits = (this.#options.domains ?? []).filter((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
46
|
+
return fits.sort((a, b) => b.length - a.length)[0] ?? this.#options.domain;
|
|
37
47
|
}
|
|
38
48
|
#path() {
|
|
39
49
|
return this.#options.path ?? "/";
|
|
@@ -41,8 +51,7 @@ export class RefreshCookie {
|
|
|
41
51
|
#legacyPaths() {
|
|
42
52
|
return (this.#options.legacyPaths ?? ["/auth"]).filter((path) => path !== this.#path());
|
|
43
53
|
}
|
|
44
|
-
#flags(path) {
|
|
45
|
-
const domain = this.#options.domain;
|
|
54
|
+
#flags(path, domain) {
|
|
46
55
|
return {
|
|
47
56
|
httpOnly: true,
|
|
48
57
|
secure: true,
|
package/dist/server/doors.js
CHANGED
|
@@ -22,18 +22,18 @@ export class AuthDoors {
|
|
|
22
22
|
async login(exchange) {
|
|
23
23
|
const subject = await this.#deps.flows.authenticateLocal(exchange);
|
|
24
24
|
const tokens = await this.#deps.sessions.start(subject, this.#deps.cookie.deviceOf(exchange.req));
|
|
25
|
-
this.#deps.cookie.issue(exchange.res, tokens);
|
|
25
|
+
this.#deps.cookie.issue(exchange.res, tokens, exchange.req);
|
|
26
26
|
return accessOf(tokens);
|
|
27
27
|
}
|
|
28
28
|
/** Rotates the cookie token and sets the successor (every refresh rotates, a racing one included); a cookie that cannot be used is cleared and the refusal rethrown. */
|
|
29
29
|
async refresh(exchange) {
|
|
30
30
|
try {
|
|
31
31
|
const tokens = await this.#deps.sessions.refresh(this.#deps.cookie.read(exchange.req));
|
|
32
|
-
this.#deps.cookie.issue(exchange.res, tokens);
|
|
32
|
+
this.#deps.cookie.issue(exchange.res, tokens, exchange.req);
|
|
33
33
|
return accessOf(tokens);
|
|
34
34
|
}
|
|
35
35
|
catch (cause) {
|
|
36
|
-
this.#deps.cookie.clear(exchange.res);
|
|
36
|
+
this.#deps.cookie.clear(exchange.res, exchange.req);
|
|
37
37
|
throw cause;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -44,7 +44,7 @@ export class AuthDoors {
|
|
|
44
44
|
/** Ends the session of the cookie and clears it. */
|
|
45
45
|
async logout(exchange) {
|
|
46
46
|
await this.#deps.sessions.end(this.#deps.cookie.read(exchange.req));
|
|
47
|
-
this.#deps.cookie.clear(exchange.res);
|
|
47
|
+
this.#deps.cookie.clear(exchange.res, exchange.req);
|
|
48
48
|
}
|
|
49
49
|
/** Sends the browser to the Google consent screen. */
|
|
50
50
|
beginGoogle(exchange) {
|
|
@@ -59,11 +59,11 @@ export class AuthDoors {
|
|
|
59
59
|
try {
|
|
60
60
|
const completion = await this.#deps.flows.completeGoogle(exchange);
|
|
61
61
|
const tokens = await this.#deps.sessions.start(completion.subject, this.#deps.cookie.deviceOf(exchange.req));
|
|
62
|
-
this.#deps.cookie.issue(exchange.res, tokens);
|
|
62
|
+
this.#deps.cookie.issue(exchange.res, tokens, exchange.req);
|
|
63
63
|
return { url: `${this.#deps.returnOrigin}${completion.next}`, statusCode: SEE_OTHER };
|
|
64
64
|
}
|
|
65
65
|
catch (cause) {
|
|
66
|
-
this.#deps.cookie.clear(exchange.res);
|
|
66
|
+
this.#deps.cookie.clear(exchange.res, exchange.req);
|
|
67
67
|
this.#deps.logger?.warn(GOOGLE_SIGN_IN_FAILED_EVENT, { cause: String(cause) });
|
|
68
68
|
return { url: `${this.#deps.returnOrigin}/sign-in?auth=error`, statusCode: SEE_OTHER };
|
|
69
69
|
}
|
package/dist/server/errors.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { DomainError } from "@starci/common";
|
|
1
2
|
import type { AuthErrorCode, AuthErrorKind } from "../contracts/index.js";
|
|
2
3
|
/** 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
|
|
4
|
-
readonly code: AuthErrorCode;
|
|
4
|
+
export declare class AuthError extends DomainError<AuthErrorCode> {
|
|
5
5
|
readonly kind: AuthErrorKind;
|
|
6
6
|
readonly context: Readonly<Record<string, unknown>>;
|
|
7
7
|
constructor(code: AuthErrorCode, options?: {
|
package/dist/server/errors.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
+
import { DomainError } from "@starci/common";
|
|
1
2
|
import { AUTH_ERROR_KINDS } from "../contracts/index.js";
|
|
2
3
|
/** 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
|
|
4
|
-
code;
|
|
4
|
+
export class AuthError extends DomainError {
|
|
5
5
|
kind;
|
|
6
6
|
context;
|
|
7
7
|
constructor(code, options = {}) {
|
|
8
|
-
super(
|
|
9
|
-
this.name = "AuthError";
|
|
10
|
-
this.code = code;
|
|
8
|
+
super(options.cause === undefined ? { code } : { code, cause: options.cause });
|
|
11
9
|
this.kind = AUTH_ERROR_KINDS[code];
|
|
12
10
|
this.context = options.context ?? {};
|
|
13
11
|
}
|
package/dist/server/flows.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import passport from "passport";
|
|
2
2
|
import { AUTH_ERROR_CODES } from "../contracts/index.js";
|
|
3
3
|
import { AuthError } from "./errors.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { isPlainRecord } from "@starci/common";
|
|
5
|
+
import { sameSitePath } from "./primitives.js";
|
|
6
|
+
const isSubject = (value) => isPlainRecord(value) && typeof value.userId === "string" && typeof value.email === "string";
|
|
6
7
|
const isHandler = (value) => typeof value === "function";
|
|
7
8
|
/** The middleware passport built; passport types it as any, so it is checked once here. */
|
|
8
9
|
const handlerOf = (built) => {
|
|
@@ -57,7 +58,7 @@ export class AuthFlows {
|
|
|
57
58
|
reject(new AuthError(AUTH_ERROR_CODES.GoogleStateInvalid));
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
60
|
-
const state =
|
|
61
|
+
const state = isPlainRecord(info) && isPlainRecord(info.state) ? info.state.next : undefined;
|
|
61
62
|
resolve({ subject: user, next: sameSitePath(state, "/") });
|
|
62
63
|
};
|
|
63
64
|
handlerOf(passport.authenticate("google", { session: false }, settled))(exchange.req, exchange.res, reject);
|
package/dist/server/options.d.ts
CHANGED
|
@@ -24,8 +24,13 @@ export interface GoogleOptions {
|
|
|
24
24
|
export interface RefreshCookieOptions {
|
|
25
25
|
/** The cookie name. */
|
|
26
26
|
readonly name: string;
|
|
27
|
-
/** The `Domain` of the cookie
|
|
27
|
+
/** The `Domain` of the cookie when the request host is under none of `domains`; undefined keeps it host-only. */
|
|
28
28
|
readonly domain?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The domains a request host may set the cookie on: a host equal to one, or ending in "." and one, gets that one as the `Domain`
|
|
31
|
+
* (the longest that fits), so one api serves sign-in on localhost (host-only) and on a tunnel origin (its parent domain).
|
|
32
|
+
*/
|
|
33
|
+
readonly domains?: readonly string[] | undefined;
|
|
29
34
|
/** 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
35
|
readonly path?: string | undefined;
|
|
31
36
|
/** The paths older sessions wrote the cookie under; each is cleared so a stale copy never shadows the live one. */
|
|
@@ -14,8 +14,6 @@ export interface SignedState {
|
|
|
14
14
|
readonly e: number;
|
|
15
15
|
readonly p?: string;
|
|
16
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
17
|
/** A same-site path (one leading slash, no line break), else the fallback path. */
|
|
20
18
|
export declare const sameSitePath: (value: unknown, fallback: string) => string;
|
|
21
19
|
/** Signs a state: base64url(JSON).base64url(HMAC-SHA256). */
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isPlainRecord } from "@starci/common";
|
|
2
3
|
import { PASSWORD_BOUNDS } from "../contracts/index.js";
|
|
3
4
|
/** The text of a secret input. */
|
|
4
5
|
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
6
|
const SAME_SITE_PATH = /^\/(?![/\\])/u;
|
|
8
7
|
const LINE_BREAK = /[\r\n]/u;
|
|
9
8
|
/** A same-site path (one leading slash, no line break), else the fallback path. */
|
|
10
9
|
export const sameSitePath = (value, fallback) => typeof value === "string" && SAME_SITE_PATH.test(value) && !LINE_BREAK.test(value) ? value : fallback;
|
|
11
10
|
const encode = (buffer) => buffer.toString("base64url");
|
|
12
|
-
const isSignedState = (value) =>
|
|
11
|
+
const isSignedState = (value) => isPlainRecord(value) &&
|
|
13
12
|
typeof value.w === "string" &&
|
|
14
13
|
typeof value.u === "string" &&
|
|
15
14
|
typeof value.r === "string" &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The sign-in of a StarCi lite app in one package: a NestJS module (EdDSA access tokens with a multi-key JWKS, bcrypt passwords, Google OAuth with state and PKCE, rotating refresh tokens with a reuse grace window and reuse detection, a failures-only sign-in limit, the refresh cookie), the door contracts, a front-end auth client (access token in memory, one shared refresh, signed out only on 401 or 403) with React bindings, and test doubles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"prepublishOnly": "npm test && npm run build"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
+
"@starci/common": "1.0.1",
|
|
77
78
|
"@nestjs/passport": "11.0.5",
|
|
78
79
|
"bcryptjs": "3.0.3",
|
|
79
80
|
"jose": "6.2.12",
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
"@nestjs/common": "11.2.5",
|
|
107
108
|
"@nestjs/core": "11.2.5",
|
|
108
109
|
"@nestjs/testing": "11.2.5",
|
|
110
|
+
"@starci/common": "file:../common",
|
|
109
111
|
"@types/express": "4.17.25",
|
|
110
112
|
"@types/node": "22.20.4",
|
|
111
113
|
"@types/passport-google-oauth20": "2.0.17",
|
|
@@ -124,5 +126,7 @@
|
|
|
124
126
|
"publishConfig": {
|
|
125
127
|
"access": "public",
|
|
126
128
|
"provenance": true
|
|
127
|
-
}
|
|
129
|
+
},
|
|
130
|
+
"gitHead": "f464f759079854716389ba92feb7e4a1512a0ee3",
|
|
131
|
+
"starciSource": "d302420a970828a385f4e2325e7ed2b3e34b10e48f9d4d552326ff9fa292d945"
|
|
128
132
|
}
|