@starci/auth 0.3.0 → 0.4.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 +5 -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/options.d.ts +6 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-11-04
|
|
4
|
+
|
|
5
|
+
- 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).
|
|
6
|
+
- 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).
|
|
7
|
+
|
|
3
8
|
## 0.3.0 - 2026-11-03
|
|
4
9
|
|
|
5
10
|
- 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/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/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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|