@stonyx/oauth 0.1.1-alpha.3 → 0.1.1-alpha.30
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/README.md +190 -2
- package/dist/auth-request.d.ts +132 -0
- package/dist/auth-request.js +235 -0
- package/dist/main.d.ts +121 -0
- package/dist/main.js +194 -0
- package/dist/oauth-flow.d.ts +30 -0
- package/dist/oauth-flow.js +83 -0
- package/dist/providers/discord.d.ts +30 -0
- package/dist/providers/discord.js +43 -0
- package/dist/session-manager.d.ts +20 -0
- package/dist/session-manager.js +30 -0
- package/dist/ticket-store.d.ts +103 -0
- package/dist/ticket-store.js +106 -0
- package/dist/token-manager.d.ts +15 -0
- package/dist/token-manager.js +24 -0
- package/package.json +40 -9
- package/src/auth-request.ts +330 -0
- package/src/main.ts +259 -0
- package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
- package/src/providers/{discord.js → discord.ts} +29 -3
- package/src/{session-manager.js → session-manager.ts} +19 -6
- package/src/ticket-store.ts +123 -0
- package/src/token-manager.ts +35 -0
- package/src/types/node.d.ts +19 -0
- package/src/types/stonyx-events.d.ts +4 -0
- package/src/types/stonyx-rest-server.d.ts +11 -0
- package/src/types/stonyx.d.ts +38 -0
- package/src/auth-request.js +0 -74
- package/src/main.js +0 -83
- package/src/token-manager.js +0 -26
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Lifetime of an exchange ticket.
|
|
4
|
+
*
|
|
5
|
+
* Sized for one redirect plus one page load, and deliberately two orders of
|
|
6
|
+
* magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
|
|
7
|
+
* travelling in a URL, and the whole point of #45 is that a bearer value in a
|
|
8
|
+
* URL must not be long-lived — in the fragment, so it reaches no server, but
|
|
9
|
+
* still into browser history and readable by scripts on the landing page.
|
|
10
|
+
*/
|
|
11
|
+
export const TICKET_TTL_MS = 60 * 1000;
|
|
12
|
+
/** Entropy of a ticket, in bytes. */
|
|
13
|
+
export const TICKET_BYTES = 32;
|
|
14
|
+
/**
|
|
15
|
+
* Single-use, short-lived tickets that stand in for a session id on the wire.
|
|
16
|
+
*
|
|
17
|
+
* The callback redirect hands the browser a ticket instead of the session id
|
|
18
|
+
* (#45), in the URL *fragment*, which no user agent transmits to any server.
|
|
19
|
+
* The ticket authenticates nothing — `GET /auth` reads the `session-id` header
|
|
20
|
+
* and knows only about `SessionManager` — so a ticket observed in history or
|
|
21
|
+
* by a script reading `location.hash` is worth something only inside the
|
|
22
|
+
* sub-second window before the landing page redeems it, and nothing at all
|
|
23
|
+
* afterwards.
|
|
24
|
+
*
|
|
25
|
+
* Known residual, stated rather than papered over: a ticket observed *within*
|
|
26
|
+
* that window is redeemable by the observer, because nothing here binds a
|
|
27
|
+
* ticket to the client that started the flow. Closing it means binding the way
|
|
28
|
+
* #36 bound the state, and that binding has to travel on a cookie the
|
|
29
|
+
* cross-origin exchange cannot carry.
|
|
30
|
+
*
|
|
31
|
+
* The blocker is `abofs/stonyx-rest-server#63`: `@stonyx/rest-server` calls
|
|
32
|
+
* `cors({ origin, methods })` and has no `credentials` support at all. It is
|
|
33
|
+
* *not* `abofs/stonyx-rest-server#45` — that issue is the response-header half
|
|
34
|
+
* and is already worked around in `auth-request.ts`, which sets and clears the
|
|
35
|
+
* binding cookie on a redirect by reaching through `req.res`. Closing #45
|
|
36
|
+
* would not make this residual closeable. It is a reduction, not an
|
|
37
|
+
* elimination.
|
|
38
|
+
*
|
|
39
|
+
* Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
|
|
40
|
+
* a pre-existing pattern in this module, not something this store introduces,
|
|
41
|
+
* and it is bounded here by a 60-second TTL rather than a 10-minute one.
|
|
42
|
+
* Tracked, with both maps named, at `abofs/stonyx-oauth#43`.
|
|
43
|
+
*
|
|
44
|
+
* ---
|
|
45
|
+
*
|
|
46
|
+
* **Why this is a second store rather than a reuse of `OAuth.pendingStates`.**
|
|
47
|
+
*
|
|
48
|
+
* The duplication is real and is not an oversight: `pendingStates` is also a
|
|
49
|
+
* single-use, TTL-bounded, consume-on-recognition map keyed by a
|
|
50
|
+
* `randomBytes`-minted opaque token, with the same delete-before-TTL-check
|
|
51
|
+
* ordering and the same never-collected caveat. The shared shape could be
|
|
52
|
+
* extracted into one primitive, and the two constants homes (`STATE_TTL_MS`
|
|
53
|
+
* and `BINDING_VALUE_BYTES` in `main.ts`, `TICKET_TTL_MS` and `TICKET_BYTES`
|
|
54
|
+
* here) could then live together.
|
|
55
|
+
*
|
|
56
|
+
* It is deliberately not done in the change that fixes #45. Widening a
|
|
57
|
+
* security fix into a refactor of the CSRF store means the #36 binding
|
|
58
|
+
* mechanism — whose invariants are load-bearing and separately guarded — moves
|
|
59
|
+
* in the same commit as the fix, for no security gain in either. The two also
|
|
60
|
+
* do not have the same invariants: `pendingStates` is a security control fed
|
|
61
|
+
* by an unauthenticated `GET`, holding a *digest* of a client secret, with a
|
|
62
|
+
* 10-minute budget sized for a provider round trip; this is a delivery
|
|
63
|
+
* convenience reachable only after a successfully bound callback, holding a
|
|
64
|
+
* value it hands back, with a 60-second budget sized for a page load.
|
|
65
|
+
* Collapsing them would couple the control to the convenience.
|
|
66
|
+
*
|
|
67
|
+
* The extraction is tracked at `abofs/stonyx-oauth#58`.
|
|
68
|
+
*/
|
|
69
|
+
export default class TicketStore {
|
|
70
|
+
tickets = new Map();
|
|
71
|
+
ttl = TICKET_TTL_MS;
|
|
72
|
+
/**
|
|
73
|
+
* Mints a ticket for a freshly created session.
|
|
74
|
+
*
|
|
75
|
+
* The ticket is independent entropy, never a transform of the session id:
|
|
76
|
+
* anything derived from the credential is the credential.
|
|
77
|
+
*/
|
|
78
|
+
issue(sessionId, expiresAt) {
|
|
79
|
+
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
80
|
+
this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
|
|
81
|
+
return ticket;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Spends a ticket, if it is live.
|
|
85
|
+
*
|
|
86
|
+
* Consumed on recognition, *before* the TTL check, for the same reason
|
|
87
|
+
* `OAuth.handleCallback` consumes a pending state before validating its
|
|
88
|
+
* binding: every ticket gets exactly one attempt whatever the outcome, so
|
|
89
|
+
* this endpoint is never a repeatable oracle. Deleting after the TTL check
|
|
90
|
+
* instead would leave an expired ticket in the map answering `400` forever
|
|
91
|
+
* while a live one answers `200` — an unauthenticated distinguisher.
|
|
92
|
+
*
|
|
93
|
+
* Returns `null` for unknown, spent and expired tickets alike. The caller
|
|
94
|
+
* maps all three to the same `400`; telling them apart is information the
|
|
95
|
+
* holder of a ticket they did not mint has no business having.
|
|
96
|
+
*/
|
|
97
|
+
redeem(ticket) {
|
|
98
|
+
const record = ticket ? this.tickets.get(ticket) : undefined;
|
|
99
|
+
if (!record)
|
|
100
|
+
return null;
|
|
101
|
+
this.tickets.delete(ticket);
|
|
102
|
+
if (Date.now() - record.createdAt > this.ttl)
|
|
103
|
+
return null;
|
|
104
|
+
return { sessionId: record.sessionId, expiresAt: record.expiresAt };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type OAuthFlow from './oauth-flow.js';
|
|
2
|
+
import type { TokenResult } from './oauth-flow.js';
|
|
3
|
+
export interface TokenData extends TokenResult {
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}
|
|
6
|
+
export default class TokenManager {
|
|
7
|
+
flow: OAuthFlow;
|
|
8
|
+
constructor(flow: OAuthFlow);
|
|
9
|
+
getTokens(code: string): Promise<TokenData>;
|
|
10
|
+
refresh(refreshToken: string): Promise<TokenData>;
|
|
11
|
+
revoke(accessToken: string): Promise<void>;
|
|
12
|
+
isExpired(tokenData: {
|
|
13
|
+
expiresAt?: number;
|
|
14
|
+
} | null | undefined): boolean;
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export default class TokenManager {
|
|
2
|
+
flow;
|
|
3
|
+
constructor(flow) {
|
|
4
|
+
this.flow = flow;
|
|
5
|
+
}
|
|
6
|
+
async getTokens(code) {
|
|
7
|
+
const tokens = await this.flow.exchangeCode(code);
|
|
8
|
+
tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
|
|
9
|
+
return tokens;
|
|
10
|
+
}
|
|
11
|
+
async refresh(refreshToken) {
|
|
12
|
+
const tokens = await this.flow.refreshAccessToken(refreshToken);
|
|
13
|
+
tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
|
|
14
|
+
return tokens;
|
|
15
|
+
}
|
|
16
|
+
async revoke(accessToken) {
|
|
17
|
+
return this.flow.revokeToken(accessToken);
|
|
18
|
+
}
|
|
19
|
+
isExpired(tokenData) {
|
|
20
|
+
if (!tokenData?.expiresAt)
|
|
21
|
+
return true;
|
|
22
|
+
return Date.now() >= tokenData.expiresAt;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -4,16 +4,39 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.1-alpha.
|
|
7
|
+
"version": "0.1.1-alpha.30",
|
|
8
8
|
"description": "OAuth2 authentication module for the Stonyx framework",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/abofs/stonyx-oauth.git"
|
|
12
12
|
},
|
|
13
|
-
"main": "
|
|
13
|
+
"main": "dist/main.js",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
|
-
".":
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/main.d.ts",
|
|
18
|
+
"default": "./dist/main.js"
|
|
19
|
+
},
|
|
20
|
+
"./oauth-flow": {
|
|
21
|
+
"types": "./dist/oauth-flow.d.ts",
|
|
22
|
+
"default": "./dist/oauth-flow.js"
|
|
23
|
+
},
|
|
24
|
+
"./auth-request": {
|
|
25
|
+
"types": "./dist/auth-request.d.ts",
|
|
26
|
+
"default": "./dist/auth-request.js"
|
|
27
|
+
},
|
|
28
|
+
"./session-manager": {
|
|
29
|
+
"types": "./dist/session-manager.d.ts",
|
|
30
|
+
"default": "./dist/session-manager.js"
|
|
31
|
+
},
|
|
32
|
+
"./token-manager": {
|
|
33
|
+
"types": "./dist/token-manager.d.ts",
|
|
34
|
+
"default": "./dist/token-manager.js"
|
|
35
|
+
},
|
|
36
|
+
"./providers/discord": {
|
|
37
|
+
"types": "./dist/providers/discord.d.ts",
|
|
38
|
+
"default": "./dist/providers/discord.js"
|
|
39
|
+
}
|
|
17
40
|
},
|
|
18
41
|
"author": "Stone Costa",
|
|
19
42
|
"license": "Apache-2.0",
|
|
@@ -21,6 +44,7 @@
|
|
|
21
44
|
"Stone Costa <stone.costa@synamicd.com>"
|
|
22
45
|
],
|
|
23
46
|
"files": [
|
|
47
|
+
"dist",
|
|
24
48
|
"src",
|
|
25
49
|
"config",
|
|
26
50
|
"README.md"
|
|
@@ -30,19 +54,26 @@
|
|
|
30
54
|
"provenance": true
|
|
31
55
|
},
|
|
32
56
|
"dependencies": {
|
|
33
|
-
"stonyx": "0.
|
|
34
|
-
"
|
|
57
|
+
"@stonyx/events": "0.1.1-beta.54",
|
|
58
|
+
"stonyx": "0.2.3-beta.82"
|
|
35
59
|
},
|
|
36
60
|
"peerDependencies": {
|
|
37
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
38
62
|
},
|
|
39
63
|
"devDependencies": {
|
|
40
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
41
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.100",
|
|
65
|
+
"@stonyx/utils": "0.2.3-beta.26",
|
|
66
|
+
"@stonyx/logs": "1.0.1-beta.20",
|
|
67
|
+
"@types/qunit": "^2.19.13",
|
|
68
|
+
"@types/sinon": "^21.0.1",
|
|
42
69
|
"qunit": "^2.24.1",
|
|
43
|
-
"sinon": "^21.0.0"
|
|
70
|
+
"sinon": "^21.0.0",
|
|
71
|
+
"tsx": "^4.21.0",
|
|
72
|
+
"typescript": "^5.8.3"
|
|
44
73
|
},
|
|
45
74
|
"scripts": {
|
|
46
|
-
"
|
|
75
|
+
"build": "tsc",
|
|
76
|
+
"build:test": "tsc -p tsconfig.test.json",
|
|
77
|
+
"test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
|
|
47
78
|
}
|
|
48
79
|
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
|
|
6
|
+
*
|
|
7
|
+
* The attributes below are load-bearing, not cosmetic:
|
|
8
|
+
*
|
|
9
|
+
* - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
|
|
10
|
+
* initiated by the provider. `Strict` withholds the cookie on exactly that
|
|
11
|
+
* request, breaking 100% of logins while passing every CSRF test; `None`
|
|
12
|
+
* requires `Secure` and widens exposure for no benefit.
|
|
13
|
+
* - `Path=/` — routing is case-insensitive today
|
|
14
|
+
* (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
|
|
15
|
+
* RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
|
|
16
|
+
* `/auth` silently drops the cookie on a case-varied callback and breaks
|
|
17
|
+
* login.
|
|
18
|
+
* - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_COOKIE_NAME = 'oauth_state';
|
|
21
|
+
const STATE_COOKIE_PATH = '/';
|
|
22
|
+
const STATE_COOKIE_SAME_SITE = 'lax';
|
|
23
|
+
|
|
24
|
+
interface AuthorizationRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
stateToken: string;
|
|
27
|
+
bindingValue: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OAuthInstance {
|
|
31
|
+
frontendCallbackUrl?: string;
|
|
32
|
+
stateTtl: number;
|
|
33
|
+
getSession(sessionId: string): unknown;
|
|
34
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
35
|
+
discardState(stateToken: string): void;
|
|
36
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
37
|
+
handleCallback(
|
|
38
|
+
providerName: string,
|
|
39
|
+
code: string,
|
|
40
|
+
stateToken: string,
|
|
41
|
+
bindingValues: readonly string[],
|
|
42
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
43
|
+
issueExchangeTicket(session: { sessionId: string; expiresAt: number }): string;
|
|
44
|
+
redeemExchangeTicket(ticket: string): { sessionId: string; expiresAt: number } | null;
|
|
45
|
+
logout(sessionId: string): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CookieOptions {
|
|
49
|
+
httpOnly: boolean;
|
|
50
|
+
sameSite: string;
|
|
51
|
+
path: string;
|
|
52
|
+
secure: boolean;
|
|
53
|
+
maxAge?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The response object express hangs off the request.
|
|
58
|
+
*
|
|
59
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
|
|
60
|
+
* is unreachable once `state.redirect` is set (`request.ts` returns on the
|
|
61
|
+
* redirect first), so setting a cookie means reaching for `req.res`.
|
|
62
|
+
*
|
|
63
|
+
* This is a deliberate, sanctioned interim reach-around, not an accident:
|
|
64
|
+
* `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
|
|
65
|
+
* first-class header/cookie affordance to migrate onto, and it is sequenced
|
|
66
|
+
* after this fix. `setBindingCookie` fails closed if the affordance is not
|
|
67
|
+
* there, which is what contains the dependency.
|
|
68
|
+
*/
|
|
69
|
+
interface ResponseLike {
|
|
70
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
71
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RouteRequest {
|
|
75
|
+
headers: Record<string, string | undefined>;
|
|
76
|
+
params: Record<string, string>;
|
|
77
|
+
query: Record<string, string>;
|
|
78
|
+
/**
|
|
79
|
+
* Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
|
|
80
|
+
*
|
|
81
|
+
* Optional and typed loosely because it is whatever an unauthenticated
|
|
82
|
+
* caller sent: a form-encoded body arrives as `null` and a bodyless request
|
|
83
|
+
* as `undefined`, so every read of it has to survive both.
|
|
84
|
+
*/
|
|
85
|
+
body?: unknown;
|
|
86
|
+
res?: ResponseLike;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface RouteState {
|
|
90
|
+
redirect?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default class AuthRequest extends Request {
|
|
94
|
+
oauth: OAuthInstance;
|
|
95
|
+
|
|
96
|
+
constructor(oauth: OAuthInstance) {
|
|
97
|
+
super();
|
|
98
|
+
this.oauth = oauth;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handlers = {
|
|
102
|
+
get: {
|
|
103
|
+
'/': ({ headers }: RouteRequest) => {
|
|
104
|
+
const sessionId = headers['session-id'];
|
|
105
|
+
if (!sessionId) return 401;
|
|
106
|
+
|
|
107
|
+
const user = this.oauth.getSession(sessionId);
|
|
108
|
+
if (!user) return 401;
|
|
109
|
+
|
|
110
|
+
return user;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
114
|
+
const { provider: providerName } = req.params;
|
|
115
|
+
|
|
116
|
+
let authorization: AuthorizationRequest;
|
|
117
|
+
try {
|
|
118
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
119
|
+
} catch {
|
|
120
|
+
return 404;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fail closed. A state we cannot bind to this client is exactly the
|
|
124
|
+
// defect this mechanism exists to prevent, so it is withdrawn rather
|
|
125
|
+
// than issued unbindable.
|
|
126
|
+
if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
|
|
127
|
+
this.oauth.discardState(authorization.stateToken);
|
|
128
|
+
return 500;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
state.redirect = authorization.url;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
135
|
+
const { provider: providerName } = req.params;
|
|
136
|
+
const { code, state: stateToken, error } = req.query;
|
|
137
|
+
|
|
138
|
+
if (error) {
|
|
139
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
140
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
return 400;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!code) return 400;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const session = await this.oauth.handleCallback(
|
|
150
|
+
providerName,
|
|
151
|
+
code,
|
|
152
|
+
stateToken,
|
|
153
|
+
this.readBindingCookies(req),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Cleared only here, on the success path, which is the only path that
|
|
157
|
+
// is certain to have consumed a state belonging to *this* client.
|
|
158
|
+
//
|
|
159
|
+
// Clearing on failure instead looks harmless and is not: `code` is
|
|
160
|
+
// attacker-supplied and unvalidated, so a bare `?code=1` — no
|
|
161
|
+
// knowledge of anyone's state — would delete the binding cookie of a
|
|
162
|
+
// client still sitting on the provider's consent screen, leaving
|
|
163
|
+
// their pending state untouched so nothing is detectable
|
|
164
|
+
// server-side, and their real callback then fails.
|
|
165
|
+
this.clearBindingCookie(req, providerName);
|
|
166
|
+
|
|
167
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
168
|
+
// The session id is the bearer credential (`GET /auth` above
|
|
169
|
+
// authenticates from exactly this value), so it must not be
|
|
170
|
+
// written into a URL: URLs land in browser history, in `Referer`
|
|
171
|
+
// on any outbound link, in proxy and CDN access logs, and in
|
|
172
|
+
// `location.search` for every script on the landing page. What
|
|
173
|
+
// goes in the URL instead is a single-use 60-second ticket that
|
|
174
|
+
// authenticates nothing, redeemed at `POST /auth/session` (#45).
|
|
175
|
+
//
|
|
176
|
+
// The ticket rides in the *fragment*, not the query. A fragment is
|
|
177
|
+
// never transmitted to any server by any user agent: it is absent
|
|
178
|
+
// from the frontend's own access logs, from every reverse proxy
|
|
179
|
+
// and CDN in front of the landing page, and from `Referer` under
|
|
180
|
+
// every referrer policy. That removes two of the four leak vectors
|
|
181
|
+
// #45 names outright, for one character. What it does not remove
|
|
182
|
+
// is browser history and readability by page scripts — those are
|
|
183
|
+
// why the ticket is still single-use and 60-second, and why the
|
|
184
|
+
// documented migration scrubs it with `history.replaceState`.
|
|
185
|
+
//
|
|
186
|
+
// `expiresAt` rides along in the same fragment rather than staying
|
|
187
|
+
// in the query, so the consumer has one place to read from.
|
|
188
|
+
// It is not a credential and nothing authenticates from it.
|
|
189
|
+
const params = new URLSearchParams({
|
|
190
|
+
ticket: this.oauth.issueExchangeTicket(session),
|
|
191
|
+
expiresAt: String(session.expiresAt),
|
|
192
|
+
});
|
|
193
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}#${params}`;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// No `frontendCallbackUrl` configured: the session is the response
|
|
198
|
+
// body of a direct request, not a value handed to a browser through
|
|
199
|
+
// a URL, so there is nothing here for #45 to fix.
|
|
200
|
+
return session;
|
|
201
|
+
} catch {
|
|
202
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
203
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
return 500;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
'/logout': ({ headers }: RouteRequest) => {
|
|
211
|
+
const sessionId = headers['session-id'];
|
|
212
|
+
if (sessionId) this.oauth.logout(sessionId);
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
post: {
|
|
217
|
+
/**
|
|
218
|
+
* Redeems the exchange ticket from the callback redirect (#45).
|
|
219
|
+
*
|
|
220
|
+
* `POST` and not `GET` because a `GET` would put the ticket back in a
|
|
221
|
+
* URL — in the caller's history, in access logs — which is the defect
|
|
222
|
+
* this route exists to close.
|
|
223
|
+
*
|
|
224
|
+
* `application/json` and not form-encoded: `@stonyx/rest-server`
|
|
225
|
+
* installs `express.json()` only, so a form-encoded body arrives as
|
|
226
|
+
* `null` and the ticket is unreadable. Measured, not assumed.
|
|
227
|
+
*
|
|
228
|
+
* Unknown, spent and expired tickets are one indistinguishable `400`.
|
|
229
|
+
*/
|
|
230
|
+
'/session': ({ body }: RouteRequest) => {
|
|
231
|
+
const ticket = (body as { ticket?: unknown } | null | undefined)?.ticket;
|
|
232
|
+
if (typeof ticket !== 'string' || !ticket) return 400;
|
|
233
|
+
|
|
234
|
+
const session = this.oauth.redeemExchangeTicket(ticket);
|
|
235
|
+
if (!session) return 400;
|
|
236
|
+
|
|
237
|
+
return { sessionId: session.sessionId, expiresAt: session.expiresAt };
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
244
|
+
*
|
|
245
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
246
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
247
|
+
* a round trip to.
|
|
248
|
+
*
|
|
249
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
250
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
251
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
252
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
253
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
254
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
255
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
256
|
+
*
|
|
257
|
+
* An unparseable or absent redirect URI fails secure.
|
|
258
|
+
*/
|
|
259
|
+
isSecureContext(providerName: string): boolean {
|
|
260
|
+
const redirectUri = this.oauth.redirectUriFor(providerName);
|
|
261
|
+
if (!redirectUri) return true;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
return new URL(redirectUri).protocol !== 'http:';
|
|
265
|
+
} catch {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
|
|
271
|
+
return {
|
|
272
|
+
httpOnly: true,
|
|
273
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
274
|
+
path: STATE_COOKIE_PATH,
|
|
275
|
+
secure: this.isSecureContext(providerName),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
|
|
280
|
+
const { res } = req;
|
|
281
|
+
|
|
282
|
+
if (typeof res?.cookie !== 'function') {
|
|
283
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
288
|
+
...this.cookieOptions(providerName),
|
|
289
|
+
maxAge: this.oauth.stateTtl,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Every value the client presented under the binding cookie's name.
|
|
297
|
+
*
|
|
298
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
299
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
300
|
+
* for any victim they can plant a same-named cookie on.
|
|
301
|
+
*/
|
|
302
|
+
readBindingCookies(req: RouteRequest): string[] {
|
|
303
|
+
const header = req.headers.cookie;
|
|
304
|
+
if (!header) return [];
|
|
305
|
+
|
|
306
|
+
const values: string[] = [];
|
|
307
|
+
|
|
308
|
+
for (const part of header.split(';')) {
|
|
309
|
+
const separator = part.indexOf('=');
|
|
310
|
+
if (separator === -1) continue;
|
|
311
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
312
|
+
|
|
313
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
314
|
+
// `encodeURIComponent` never escapes, so decoding buys nothing — and
|
|
315
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
316
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
317
|
+
// callback into a 500.
|
|
318
|
+
values.push(part.slice(separator + 1).trim());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return values;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
clearBindingCookie(req: RouteRequest, providerName: string): void {
|
|
325
|
+
const { res } = req;
|
|
326
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
327
|
+
|
|
328
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
|
|
329
|
+
}
|
|
330
|
+
}
|