@stonyx/oauth 0.1.1-alpha.14 → 0.1.1-alpha.16

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 CHANGED
@@ -55,10 +55,74 @@ The module self-registers the following routes on the rest server:
55
55
  | Method | Route | Description |
56
56
  |--------|-------|-------------|
57
57
  | `GET` | `/auth` | Validate session — send `session-id` header, returns user or 401 |
58
- | `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page |
59
- | `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session |
58
+ | `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page, and sets the state binding cookie |
59
+ | `GET` | `/auth/callback/:provider` | OAuth2 callback — verifies the state binding, exchanges code for tokens, creates session |
60
60
  | `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
61
61
 
62
+ ### Starting the flow
63
+
64
+ Send the browser to `/auth/login/:provider` as a **top-level navigation**:
65
+
66
+ ```javascript
67
+ window.location.href = 'https://api.example.com/auth/login/discord';
68
+ ```
69
+
70
+ Do not start the flow with `fetch()` or `XMLHttpRequest`. The login response
71
+ sets the state binding cookie described below, and the browser must be holding
72
+ that cookie when the provider redirects it back to `/auth/callback/:provider`.
73
+
74
+ ## State Binding (CSRF Protection)
75
+
76
+ The OAuth2 `state` parameter only protects against login CSRF if it is bound to
77
+ the client that started the flow. This module binds it with a cookie.
78
+
79
+ On `GET /auth/login/:provider` the module issues a random 32-byte binding value,
80
+ stores only a SHA-256 digest of it server-side alongside the provider name and
81
+ issue time, and sends the plaintext to the client as a cookie:
82
+
83
+ | Attribute | Value | Why |
84
+ |-----------|-------|-----|
85
+ | Name | `stonyx_oauth_state` | |
86
+ | `HttpOnly` | set | script must not be able to read or forge the binding value |
87
+ | `SameSite` | `Lax` | **required.** The callback is a cross-site, top-level `GET` navigation from the provider. `SameSite=Strict` withholds the cookie on exactly that request and breaks login outright; `SameSite=None` requires `Secure` and widens exposure for no benefit |
88
+ | `Path` | `/auth` | the cookie is only ever read by the callback route |
89
+ | `Secure` | set when the request arrived over HTTPS | |
90
+ | `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
91
+
92
+ `GET /auth/callback/:provider` accepts the callback only when all of the
93
+ following hold, and mints no session otherwise:
94
+
95
+ - the `state` is one this server issued and has not already been used
96
+ - it was issued for **this** provider
97
+ - it was issued less than 10 minutes ago
98
+ - the request carries the binding cookie whose value hashes to the stored digest
99
+
100
+ The state and the cookie are both single-use: the pending record is consumed on
101
+ any callback that presents a recognised `state` — successful or not — and the
102
+ callback response clears the cookie.
103
+
104
+ If the cookie cannot be set, `GET /auth/login/:provider` responds `500` rather
105
+ than issuing a state it cannot bind. A reverse proxy or CDN that strips
106
+ `Set-Cookie` from redirect responses will therefore break login rather than
107
+ silently degrade it.
108
+
109
+ ### Custom flow drivers
110
+
111
+ Consumers that drive the flow themselves instead of using the routes above must
112
+ carry the binding value between the two calls:
113
+
114
+ ```javascript
115
+ const { url, bindingValue } = oauth.getAuthorizationUrl('discord');
116
+ // hand bindingValue to the client, then on the callback:
117
+ const session = await oauth.handleCallback('discord', code, state, bindingValue);
118
+ ```
119
+
120
+ > **Changed in the release that fixes [#36](https://github.com/abofs/stonyx-oauth/issues/36):**
121
+ > `getAuthorizationUrl(provider)` returned a URL string and now returns
122
+ > `{ url, bindingValue }`; `handleCallback(provider, code, state)` takes a
123
+ > fourth argument, the client's binding value. Applications using the
124
+ > self-registering `/auth` routes need no changes.
125
+
62
126
  ## Officially Supported Providers
63
127
 
64
128
  ### Discord
@@ -119,6 +183,8 @@ providers: {
119
183
  ## Session Management
120
184
 
121
185
  Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
186
+ Pending OAuth states are held in-memory too, so a restart mid-login, or more
187
+ than one instance behind a load balancer, will reject the callback.
122
188
 
123
189
  Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
124
190
 
@@ -1,18 +1,44 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
+ interface AuthorizationRequest {
3
+ url: string;
4
+ bindingValue: string;
5
+ }
2
6
  interface OAuthInstance {
3
7
  frontendCallbackUrl?: string;
4
8
  getSession(sessionId: string): unknown;
5
- getAuthorizationUrl(providerName: string): string;
6
- handleCallback(providerName: string, code: string, stateToken: string): Promise<{
9
+ getAuthorizationUrl(providerName: string): AuthorizationRequest;
10
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string): Promise<{
7
11
  sessionId: string;
8
12
  expiresAt: number;
9
13
  }>;
10
14
  logout(sessionId: string): void;
11
15
  }
16
+ interface CookieOptions {
17
+ httpOnly: boolean;
18
+ sameSite: string;
19
+ path: string;
20
+ secure: boolean;
21
+ maxAge?: number;
22
+ }
23
+ /**
24
+ * The response object Express hangs off the request.
25
+ *
26
+ * `@stonyx/rest-server` hands handlers `(req, state)` only, and `state` has no
27
+ * affordance for response headers, so setting a cookie means reaching for
28
+ * `req.res`. This is a deliberate, temporary escape hatch — tracked by
29
+ * `abofs/stonyx-rest-server#45`, which adds a first-class header affordance to
30
+ * migrate onto.
31
+ */
32
+ interface ResponseLike {
33
+ cookie(name: string, value: string, options: CookieOptions): unknown;
34
+ clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
35
+ }
12
36
  interface RouteRequest {
13
37
  headers: Record<string, string | undefined>;
14
38
  params: Record<string, string>;
15
39
  query: Record<string, string>;
40
+ secure?: boolean;
41
+ res?: ResponseLike;
16
42
  }
17
43
  interface RouteState {
18
44
  redirect?: string;
@@ -23,13 +49,17 @@ export default class AuthRequest extends Request {
23
49
  handlers: {
24
50
  get: {
25
51
  '/': ({ headers }: RouteRequest) => {};
26
- '/login/:provider': (req: RouteRequest, state: RouteState) => 404 | undefined;
52
+ '/login/:provider': (req: RouteRequest, state: RouteState) => 404 | 500 | undefined;
27
53
  '/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
28
54
  sessionId: string;
29
55
  expiresAt: number;
30
- } | 400 | 500 | undefined>;
56
+ } | 500 | 400 | undefined>;
31
57
  '/logout': ({ headers }: RouteRequest) => void;
32
58
  };
33
59
  };
60
+ cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
61
+ setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
62
+ readBindingCookie(req: RouteRequest): string | undefined;
63
+ clearBindingCookie(req: RouteRequest): void;
34
64
  }
35
65
  export {};
@@ -1,4 +1,6 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
+ import log from 'stonyx/log';
3
+ import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
2
4
  export default class AuthRequest extends Request {
3
5
  oauth;
4
6
  constructor(oauth) {
@@ -18,17 +20,26 @@ export default class AuthRequest extends Request {
18
20
  },
19
21
  '/login/:provider': (req, state) => {
20
22
  const { provider: providerName } = req.params;
23
+ let authorization;
21
24
  try {
22
- const url = this.oauth.getAuthorizationUrl(providerName);
23
- state.redirect = url;
25
+ authorization = this.oauth.getAuthorizationUrl(providerName);
24
26
  }
25
27
  catch {
26
28
  return 404;
27
29
  }
30
+ // Fail closed: a state we cannot bind to this client is exactly the
31
+ // defect this mechanism exists to prevent, so never issue one.
32
+ if (!this.setBindingCookie(req, authorization.bindingValue))
33
+ return 500;
34
+ state.redirect = authorization.url;
28
35
  },
29
36
  '/callback/:provider': async (req, state) => {
30
37
  const { provider: providerName } = req.params;
31
38
  const { code, state: stateToken, error } = req.query;
39
+ // The binding value is single-use: whatever the outcome below, this
40
+ // callback is the end of that cookie's life.
41
+ const bindingValue = this.readBindingCookie(req);
42
+ this.clearBindingCookie(req);
32
43
  if (error) {
33
44
  if (this.oauth.frontendCallbackUrl) {
34
45
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -39,7 +50,7 @@ export default class AuthRequest extends Request {
39
50
  if (!code)
40
51
  return 400;
41
52
  try {
42
- const session = await this.oauth.handleCallback(providerName, code, stateToken);
53
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
43
54
  if (this.oauth.frontendCallbackUrl) {
44
55
  const params = new URLSearchParams({
45
56
  sessionId: session.sessionId,
@@ -65,4 +76,47 @@ export default class AuthRequest extends Request {
65
76
  },
66
77
  }
67
78
  };
79
+ cookieOptions(req) {
80
+ return {
81
+ httpOnly: true,
82
+ // Load-bearing: the callback is a cross-site top-level GET navigation
83
+ // from the provider. `Strict` withholds the cookie on exactly that
84
+ // request and breaks login outright.
85
+ sameSite: STATE_COOKIE_SAME_SITE,
86
+ path: STATE_COOKIE_PATH,
87
+ secure: req.secure === true,
88
+ };
89
+ }
90
+ setBindingCookie(req, bindingValue) {
91
+ const { res } = req;
92
+ if (typeof res?.cookie !== 'function') {
93
+ log.error('OAuth: unable to set the state binding cookie; login rejected');
94
+ return false;
95
+ }
96
+ res.cookie(STATE_COOKIE_NAME, bindingValue, {
97
+ ...this.cookieOptions(req),
98
+ maxAge: STATE_TTL_MS,
99
+ });
100
+ return true;
101
+ }
102
+ readBindingCookie(req) {
103
+ const header = req.headers.cookie;
104
+ if (!header)
105
+ return undefined;
106
+ for (const part of header.split(';')) {
107
+ const separator = part.indexOf('=');
108
+ if (separator === -1)
109
+ continue;
110
+ if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
111
+ continue;
112
+ return decodeURIComponent(part.slice(separator + 1).trim());
113
+ }
114
+ return undefined;
115
+ }
116
+ clearBindingCookie(req) {
117
+ const { res } = req;
118
+ if (typeof res?.clearCookie !== 'function')
119
+ return;
120
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
121
+ }
68
122
  }
@@ -0,0 +1,7 @@
1
+ export declare const STATE_COOKIE_NAME = "stonyx_oauth_state";
2
+ export declare const STATE_COOKIE_PATH = "/auth";
3
+ export declare const STATE_COOKIE_SAME_SITE = "lax";
4
+ /** Lifetime of a pending state record, and the binding cookie's Max-Age. */
5
+ export declare const STATE_TTL_MS: number;
6
+ /** Entropy of the client-held binding value, in bytes. */
7
+ export declare const BINDING_VALUE_BYTES = 32;
@@ -0,0 +1,16 @@
1
+ // Shared constants for the OAuth state/client-binding mechanism (#36).
2
+ //
3
+ // The binding cookie attributes are load-bearing, not cosmetic:
4
+ // - `SameSite=Lax` — the OAuth callback is a cross-site, top-level GET
5
+ // navigation initiated by the provider. `Strict` withholds the cookie on
6
+ // exactly that request and breaks login; `None` requires `Secure` and
7
+ // widens exposure for no benefit. `Lax` is the only correct value.
8
+ // - `Path=/auth` — the cookie is only ever read by the callback route.
9
+ // - `HttpOnly` — script must not be able to read or forge the binding value.
10
+ export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
11
+ export const STATE_COOKIE_PATH = '/auth';
12
+ export const STATE_COOKIE_SAME_SITE = 'lax';
13
+ /** Lifetime of a pending state record, and the binding cookie's Max-Age. */
14
+ export const STATE_TTL_MS = 10 * 60 * 1000;
15
+ /** Entropy of the client-held binding value, in bytes. */
16
+ export const BINDING_VALUE_BYTES = 32;
package/dist/main.d.ts CHANGED
@@ -1,21 +1,32 @@
1
1
  import TokenManager from './token-manager.js';
2
2
  import SessionManager from './session-manager.js';
3
+ import StateStore from './state-store.js';
3
4
  import type OAuthFlow from './oauth-flow.js';
4
5
  interface ProviderEntry {
5
6
  flow: OAuthFlow;
6
7
  tokenManager: TokenManager;
7
8
  }
9
+ export interface AuthorizationRequest {
10
+ /** Provider authorization URL to redirect the client to. */
11
+ url: string;
12
+ /**
13
+ * Client-held half of the state binding (#36). The caller must hand this to
14
+ * the client that started the flow — the auth routes set it as an HttpOnly
15
+ * cookie — and present it back to `handleCallback`.
16
+ */
17
+ bindingValue: string;
18
+ }
8
19
  export default class OAuth {
9
20
  static instance: OAuth | null;
10
21
  providers: Map<string, ProviderEntry>;
11
- pendingStates: Map<string, number>;
22
+ stateStore: StateStore;
12
23
  sessionManager: SessionManager;
13
24
  frontendCallbackUrl?: string;
14
25
  constructor();
15
26
  init(): Promise<void>;
16
27
  getProvider(name: string): ProviderEntry;
17
- getAuthorizationUrl(providerName: string): string;
18
- handleCallback(providerName: string, code: string, stateToken: string): Promise<import("./session-manager.js").SessionResult>;
28
+ getAuthorizationUrl(providerName: string): AuthorizationRequest;
29
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string): Promise<import("./session-manager.js").SessionResult>;
19
30
  getSession(sessionId: string): unknown;
20
31
  logout(sessionId: string): void;
21
32
  }
package/dist/main.js CHANGED
@@ -6,11 +6,12 @@ import RestServer from '@stonyx/rest-server';
6
6
  import TokenManager from './token-manager.js';
7
7
  import SessionManager from './session-manager.js';
8
8
  import AuthRequest from './auth-request.js';
9
+ import StateStore from './state-store.js';
9
10
  setup(['authenticate']);
10
11
  export default class OAuth {
11
12
  static instance;
12
13
  providers = new Map();
13
- pendingStates = new Map();
14
+ stateStore = new StateStore();
14
15
  sessionManager;
15
16
  frontendCallbackUrl;
16
17
  constructor() {
@@ -19,6 +20,10 @@ export default class OAuth {
19
20
  OAuth.instance = this;
20
21
  }
21
22
  async init() {
23
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
24
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
25
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
26
+ log.defineType(logMethod, logColor);
22
27
  const oauthConfig = config.oauth;
23
28
  const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
24
29
  this.frontendCallbackUrl = frontendCallbackUrl;
@@ -43,22 +48,11 @@ export default class OAuth {
43
48
  }
44
49
  getAuthorizationUrl(providerName) {
45
50
  const { flow } = this.getProvider(providerName);
46
- const stateToken = crypto.randomUUID();
47
- this.pendingStates.set(stateToken, Date.now());
48
- return flow.buildAuthorizationUrl(stateToken);
51
+ const { stateToken, bindingValue } = this.stateStore.issue(providerName);
52
+ return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
49
53
  }
50
- async handleCallback(providerName, code, stateToken) {
51
- if (!stateToken || !this.pendingStates.has(stateToken)) {
52
- throw new Error('Invalid or missing state token');
53
- }
54
- const stateCreatedAt = this.pendingStates.get(stateToken);
55
- if (stateCreatedAt === undefined)
56
- throw new Error('State token not found in pending states');
57
- this.pendingStates.delete(stateToken);
58
- const TEN_MINUTES = 10 * 60 * 1000;
59
- if (Date.now() - stateCreatedAt > TEN_MINUTES) {
60
- throw new Error('State token has expired');
61
- }
54
+ async handleCallback(providerName, code, stateToken, bindingValue) {
55
+ this.stateStore.consume(stateToken, providerName, bindingValue);
62
56
  const { flow, tokenManager } = this.getProvider(providerName);
63
57
  const tokens = await tokenManager.getTokens(code);
64
58
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Server-side record for an OAuth flow that is in progress.
3
+ *
4
+ * Deliberately holds a *digest* of the binding value rather than the value
5
+ * itself: a callback is only accepted when the caller presents the plaintext
6
+ * that hashes to `bindingHash`, so the record on its own unlocks nothing.
7
+ */
8
+ export interface PendingState {
9
+ provider: string;
10
+ bindingHash: string;
11
+ createdAt: number;
12
+ }
13
+ export interface IssuedState {
14
+ /** Sent to the provider as the OAuth2 `state` parameter. */
15
+ stateToken: string;
16
+ /** Held by the client that started the flow (a cookie), never by the provider. */
17
+ bindingValue: string;
18
+ }
19
+ /**
20
+ * Issues and validates OAuth2 `state` tokens bound to the client that started
21
+ * the flow (#36).
22
+ *
23
+ * Presence-plus-age on a process-global map is replay-window limiting, not the
24
+ * CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
25
+ * issued to any visitor validated for any callback, so an attacker could
26
+ * harvest their own state and code and deliver them to a victim, logging the
27
+ * victim in as the attacker. A state is now only accepted when the caller also
28
+ * presents the matching client-held binding value, and only at the provider it
29
+ * was issued for.
30
+ */
31
+ export default class StateStore {
32
+ pending: Map<string, PendingState>;
33
+ ttl: number;
34
+ constructor(ttl?: number);
35
+ static hash(value: string): string;
36
+ /** Length-independent, content-constant-time comparison of two digests. */
37
+ static digestsMatch(a: string, b: string): boolean;
38
+ issue(provider: string): IssuedState;
39
+ /**
40
+ * Validates and consumes a pending state. Throws on every rejection path.
41
+ *
42
+ * The record is removed as soon as the state is recognised — before the
43
+ * binding is checked — so a state cannot survive a failed attempt and be
44
+ * used as a target for guessing the binding value.
45
+ */
46
+ consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
47
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
3
+ /**
4
+ * Issues and validates OAuth2 `state` tokens bound to the client that started
5
+ * the flow (#36).
6
+ *
7
+ * Presence-plus-age on a process-global map is replay-window limiting, not the
8
+ * CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
9
+ * issued to any visitor validated for any callback, so an attacker could
10
+ * harvest their own state and code and deliver them to a victim, logging the
11
+ * victim in as the attacker. A state is now only accepted when the caller also
12
+ * presents the matching client-held binding value, and only at the provider it
13
+ * was issued for.
14
+ */
15
+ export default class StateStore {
16
+ pending = new Map();
17
+ ttl;
18
+ constructor(ttl = STATE_TTL_MS) {
19
+ this.ttl = ttl;
20
+ }
21
+ static hash(value) {
22
+ return createHash('sha256').update(value).digest('hex');
23
+ }
24
+ /** Length-independent, content-constant-time comparison of two digests. */
25
+ static digestsMatch(a, b) {
26
+ if (a.length !== b.length)
27
+ return false;
28
+ let difference = 0;
29
+ for (let index = 0; index < a.length; index++) {
30
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
31
+ }
32
+ return difference === 0;
33
+ }
34
+ issue(provider) {
35
+ const stateToken = randomUUID();
36
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
37
+ this.pending.set(stateToken, {
38
+ provider,
39
+ bindingHash: StateStore.hash(bindingValue),
40
+ createdAt: Date.now(),
41
+ });
42
+ return { stateToken, bindingValue };
43
+ }
44
+ /**
45
+ * Validates and consumes a pending state. Throws on every rejection path.
46
+ *
47
+ * The record is removed as soon as the state is recognised — before the
48
+ * binding is checked — so a state cannot survive a failed attempt and be
49
+ * used as a target for guessing the binding value.
50
+ */
51
+ consume(stateToken, provider, bindingValue) {
52
+ if (!stateToken)
53
+ throw new Error('Invalid or missing state token');
54
+ const record = this.pending.get(stateToken);
55
+ if (!record)
56
+ throw new Error('Invalid or missing state token');
57
+ this.pending.delete(stateToken);
58
+ if (Date.now() - record.createdAt > this.ttl)
59
+ throw new Error('State token has expired');
60
+ if (record.provider !== provider)
61
+ throw new Error('State token was not issued for this provider');
62
+ if (!bindingValue)
63
+ throw new Error('Missing state binding value');
64
+ if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
65
+ throw new Error('State token is not bound to this client');
66
+ }
67
+ }
68
+ }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.14",
7
+ "version": "0.1.1-alpha.16",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/main.d.ts",
18
18
  "default": "./dist/main.js"
19
19
  },
20
+ "./constants": {
21
+ "types": "./dist/constants.d.ts",
22
+ "default": "./dist/constants.js"
23
+ },
20
24
  "./oauth-flow": {
21
25
  "types": "./dist/oauth-flow.d.ts",
22
26
  "default": "./dist/oauth-flow.js"
@@ -54,16 +58,16 @@
54
58
  "provenance": true
55
59
  },
56
60
  "dependencies": {
57
- "@stonyx/events": "0.1.1-beta.47",
58
- "stonyx": "0.2.3-beta.63"
61
+ "@stonyx/events": "0.1.1-beta.52",
62
+ "stonyx": "0.2.3-beta.76"
59
63
  },
60
64
  "peerDependencies": {
61
65
  "@stonyx/rest-server": ">=0.2.1-beta.11"
62
66
  },
63
67
  "devDependencies": {
64
- "@stonyx/rest-server": "0.2.1-beta.59",
65
- "@stonyx/utils": "0.2.3-beta.23",
66
- "@stonyx/logs": "1.0.1-beta.16",
68
+ "@stonyx/rest-server": "0.2.1-beta.80",
69
+ "@stonyx/utils": "0.2.3-beta.26",
70
+ "@stonyx/logs": "1.0.1-beta.19",
67
71
  "@types/qunit": "^2.19.13",
68
72
  "@types/sinon": "^21.0.1",
69
73
  "qunit": "^2.24.1",
@@ -1,17 +1,58 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
+ import log from 'stonyx/log';
3
+ import {
4
+ STATE_COOKIE_NAME,
5
+ STATE_COOKIE_PATH,
6
+ STATE_COOKIE_SAME_SITE,
7
+ STATE_TTL_MS,
8
+ } from './constants.js';
9
+
10
+ interface AuthorizationRequest {
11
+ url: string;
12
+ bindingValue: string;
13
+ }
2
14
 
3
15
  interface OAuthInstance {
4
16
  frontendCallbackUrl?: string;
5
17
  getSession(sessionId: string): unknown;
6
- getAuthorizationUrl(providerName: string): string;
7
- handleCallback(providerName: string, code: string, stateToken: string): Promise<{ sessionId: string; expiresAt: number }>;
18
+ getAuthorizationUrl(providerName: string): AuthorizationRequest;
19
+ handleCallback(
20
+ providerName: string,
21
+ code: string,
22
+ stateToken: string,
23
+ bindingValue?: string,
24
+ ): Promise<{ sessionId: string; expiresAt: number }>;
8
25
  logout(sessionId: string): void;
9
26
  }
10
27
 
28
+ interface CookieOptions {
29
+ httpOnly: boolean;
30
+ sameSite: string;
31
+ path: string;
32
+ secure: boolean;
33
+ maxAge?: number;
34
+ }
35
+
36
+ /**
37
+ * The response object Express hangs off the request.
38
+ *
39
+ * `@stonyx/rest-server` hands handlers `(req, state)` only, and `state` has no
40
+ * affordance for response headers, so setting a cookie means reaching for
41
+ * `req.res`. This is a deliberate, temporary escape hatch — tracked by
42
+ * `abofs/stonyx-rest-server#45`, which adds a first-class header affordance to
43
+ * migrate onto.
44
+ */
45
+ interface ResponseLike {
46
+ cookie(name: string, value: string, options: CookieOptions): unknown;
47
+ clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
48
+ }
49
+
11
50
  interface RouteRequest {
12
51
  headers: Record<string, string | undefined>;
13
52
  params: Record<string, string>;
14
53
  query: Record<string, string>;
54
+ secure?: boolean;
55
+ res?: ResponseLike;
15
56
  }
16
57
 
17
58
  interface RouteState {
@@ -41,18 +82,29 @@ export default class AuthRequest extends Request {
41
82
  '/login/:provider': (req: RouteRequest, state: RouteState) => {
42
83
  const { provider: providerName } = req.params;
43
84
 
85
+ let authorization: AuthorizationRequest;
44
86
  try {
45
- const url = this.oauth.getAuthorizationUrl(providerName);
46
- state.redirect = url;
87
+ authorization = this.oauth.getAuthorizationUrl(providerName);
47
88
  } catch {
48
89
  return 404;
49
90
  }
91
+
92
+ // Fail closed: a state we cannot bind to this client is exactly the
93
+ // defect this mechanism exists to prevent, so never issue one.
94
+ if (!this.setBindingCookie(req, authorization.bindingValue)) return 500;
95
+
96
+ state.redirect = authorization.url;
50
97
  },
51
98
 
52
99
  '/callback/:provider': async (req: RouteRequest, state: RouteState) => {
53
100
  const { provider: providerName } = req.params;
54
101
  const { code, state: stateToken, error } = req.query;
55
102
 
103
+ // The binding value is single-use: whatever the outcome below, this
104
+ // callback is the end of that cookie's life.
105
+ const bindingValue = this.readBindingCookie(req);
106
+ this.clearBindingCookie(req);
107
+
56
108
  if (error) {
57
109
  if (this.oauth.frontendCallbackUrl) {
58
110
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -64,7 +116,7 @@ export default class AuthRequest extends Request {
64
116
  if (!code) return 400;
65
117
 
66
118
  try {
67
- const session = await this.oauth.handleCallback(providerName, code, stateToken);
119
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
68
120
 
69
121
  if (this.oauth.frontendCallbackUrl) {
70
122
  const params = new URLSearchParams({
@@ -91,4 +143,54 @@ export default class AuthRequest extends Request {
91
143
  },
92
144
  }
93
145
  };
146
+
147
+ cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'> {
148
+ return {
149
+ httpOnly: true,
150
+ // Load-bearing: the callback is a cross-site top-level GET navigation
151
+ // from the provider. `Strict` withholds the cookie on exactly that
152
+ // request and breaks login outright.
153
+ sameSite: STATE_COOKIE_SAME_SITE,
154
+ path: STATE_COOKIE_PATH,
155
+ secure: req.secure === true,
156
+ };
157
+ }
158
+
159
+ setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
160
+ const { res } = req;
161
+
162
+ if (typeof res?.cookie !== 'function') {
163
+ log.error('OAuth: unable to set the state binding cookie; login rejected');
164
+ return false;
165
+ }
166
+
167
+ res.cookie(STATE_COOKIE_NAME, bindingValue, {
168
+ ...this.cookieOptions(req),
169
+ maxAge: STATE_TTL_MS,
170
+ });
171
+
172
+ return true;
173
+ }
174
+
175
+ readBindingCookie(req: RouteRequest): string | undefined {
176
+ const header = req.headers.cookie;
177
+ if (!header) return undefined;
178
+
179
+ for (const part of header.split(';')) {
180
+ const separator = part.indexOf('=');
181
+ if (separator === -1) continue;
182
+ if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
183
+
184
+ return decodeURIComponent(part.slice(separator + 1).trim());
185
+ }
186
+
187
+ return undefined;
188
+ }
189
+
190
+ clearBindingCookie(req: RouteRequest): void {
191
+ const { res } = req;
192
+ if (typeof res?.clearCookie !== 'function') return;
193
+
194
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
195
+ }
94
196
  }
@@ -0,0 +1,19 @@
1
+ // Shared constants for the OAuth state/client-binding mechanism (#36).
2
+ //
3
+ // The binding cookie attributes are load-bearing, not cosmetic:
4
+ // - `SameSite=Lax` — the OAuth callback is a cross-site, top-level GET
5
+ // navigation initiated by the provider. `Strict` withholds the cookie on
6
+ // exactly that request and breaks login; `None` requires `Secure` and
7
+ // widens exposure for no benefit. `Lax` is the only correct value.
8
+ // - `Path=/auth` — the cookie is only ever read by the callback route.
9
+ // - `HttpOnly` — script must not be able to read or forge the binding value.
10
+
11
+ export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
12
+ export const STATE_COOKIE_PATH = '/auth';
13
+ export const STATE_COOKIE_SAME_SITE = 'lax';
14
+
15
+ /** Lifetime of a pending state record, and the binding cookie's Max-Age. */
16
+ export const STATE_TTL_MS = 10 * 60 * 1000;
17
+
18
+ /** Entropy of the client-held binding value, in bytes. */
19
+ export const BINDING_VALUE_BYTES = 32;
package/src/main.ts CHANGED
@@ -6,6 +6,7 @@ import RestServer from '@stonyx/rest-server';
6
6
  import TokenManager from './token-manager.js';
7
7
  import SessionManager from './session-manager.js';
8
8
  import AuthRequest from './auth-request.js';
9
+ import StateStore from './state-store.js';
9
10
  import type OAuthFlow from './oauth-flow.js';
10
11
 
11
12
  setup(['authenticate']);
@@ -20,11 +21,22 @@ interface ProviderConfig {
20
21
  [key: string]: unknown;
21
22
  }
22
23
 
24
+ export interface AuthorizationRequest {
25
+ /** Provider authorization URL to redirect the client to. */
26
+ url: string;
27
+ /**
28
+ * Client-held half of the state binding (#36). The caller must hand this to
29
+ * the client that started the flow — the auth routes set it as an HttpOnly
30
+ * cookie — and present it back to `handleCallback`.
31
+ */
32
+ bindingValue: string;
33
+ }
34
+
23
35
  export default class OAuth {
24
36
  static instance: OAuth | null;
25
37
 
26
38
  providers = new Map<string, ProviderEntry>();
27
- pendingStates = new Map<string, number>();
39
+ stateStore = new StateStore();
28
40
  sessionManager!: SessionManager;
29
41
  frontendCallbackUrl?: string;
30
42
 
@@ -34,6 +46,11 @@ export default class OAuth {
34
46
  }
35
47
 
36
48
  async init(): Promise<void> {
49
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
50
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
51
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
52
+ log.defineType(logMethod, logColor);
53
+
37
54
  const oauthConfig = config.oauth;
38
55
  const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
39
56
  this.frontendCallbackUrl = frontendCallbackUrl;
@@ -61,26 +78,15 @@ export default class OAuth {
61
78
  return provider;
62
79
  }
63
80
 
64
- getAuthorizationUrl(providerName: string): string {
81
+ getAuthorizationUrl(providerName: string): AuthorizationRequest {
65
82
  const { flow } = this.getProvider(providerName);
66
- const stateToken = crypto.randomUUID();
67
- this.pendingStates.set(stateToken, Date.now());
68
- return flow.buildAuthorizationUrl(stateToken);
69
- }
83
+ const { stateToken, bindingValue } = this.stateStore.issue(providerName);
70
84
 
71
- async handleCallback(providerName: string, code: string, stateToken: string) {
72
- if (!stateToken || !this.pendingStates.has(stateToken)) {
73
- throw new Error('Invalid or missing state token');
74
- }
75
-
76
- const stateCreatedAt = this.pendingStates.get(stateToken);
77
- if (stateCreatedAt === undefined) throw new Error('State token not found in pending states');
78
- this.pendingStates.delete(stateToken);
85
+ return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
86
+ }
79
87
 
80
- const TEN_MINUTES = 10 * 60 * 1000;
81
- if (Date.now() - stateCreatedAt > TEN_MINUTES) {
82
- throw new Error('State token has expired');
83
- }
88
+ async handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string) {
89
+ this.stateStore.consume(stateToken, providerName, bindingValue);
84
90
 
85
91
  const { flow, tokenManager } = this.getProvider(providerName);
86
92
  const tokens = await tokenManager.getTokens(code);
@@ -0,0 +1,95 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
3
+
4
+ /**
5
+ * Server-side record for an OAuth flow that is in progress.
6
+ *
7
+ * Deliberately holds a *digest* of the binding value rather than the value
8
+ * itself: a callback is only accepted when the caller presents the plaintext
9
+ * that hashes to `bindingHash`, so the record on its own unlocks nothing.
10
+ */
11
+ export interface PendingState {
12
+ provider: string;
13
+ bindingHash: string;
14
+ createdAt: number;
15
+ }
16
+
17
+ export interface IssuedState {
18
+ /** Sent to the provider as the OAuth2 `state` parameter. */
19
+ stateToken: string;
20
+ /** Held by the client that started the flow (a cookie), never by the provider. */
21
+ bindingValue: string;
22
+ }
23
+
24
+ /**
25
+ * Issues and validates OAuth2 `state` tokens bound to the client that started
26
+ * the flow (#36).
27
+ *
28
+ * Presence-plus-age on a process-global map is replay-window limiting, not the
29
+ * CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
30
+ * issued to any visitor validated for any callback, so an attacker could
31
+ * harvest their own state and code and deliver them to a victim, logging the
32
+ * victim in as the attacker. A state is now only accepted when the caller also
33
+ * presents the matching client-held binding value, and only at the provider it
34
+ * was issued for.
35
+ */
36
+ export default class StateStore {
37
+ pending = new Map<string, PendingState>();
38
+ ttl: number;
39
+
40
+ constructor(ttl: number = STATE_TTL_MS) {
41
+ this.ttl = ttl;
42
+ }
43
+
44
+ static hash(value: string): string {
45
+ return createHash('sha256').update(value).digest('hex');
46
+ }
47
+
48
+ /** Length-independent, content-constant-time comparison of two digests. */
49
+ static digestsMatch(a: string, b: string): boolean {
50
+ if (a.length !== b.length) return false;
51
+
52
+ let difference = 0;
53
+ for (let index = 0; index < a.length; index++) {
54
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
55
+ }
56
+
57
+ return difference === 0;
58
+ }
59
+
60
+ issue(provider: string): IssuedState {
61
+ const stateToken = randomUUID();
62
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
63
+
64
+ this.pending.set(stateToken, {
65
+ provider,
66
+ bindingHash: StateStore.hash(bindingValue),
67
+ createdAt: Date.now(),
68
+ });
69
+
70
+ return { stateToken, bindingValue };
71
+ }
72
+
73
+ /**
74
+ * Validates and consumes a pending state. Throws on every rejection path.
75
+ *
76
+ * The record is removed as soon as the state is recognised — before the
77
+ * binding is checked — so a state cannot survive a failed attempt and be
78
+ * used as a target for guessing the binding value.
79
+ */
80
+ consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
81
+ if (!stateToken) throw new Error('Invalid or missing state token');
82
+
83
+ const record = this.pending.get(stateToken);
84
+ if (!record) throw new Error('Invalid or missing state token');
85
+ this.pending.delete(stateToken);
86
+
87
+ if (Date.now() - record.createdAt > this.ttl) throw new Error('State token has expired');
88
+ if (record.provider !== provider) throw new Error('State token was not issued for this provider');
89
+ if (!bindingValue) throw new Error('Missing state binding value');
90
+
91
+ if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
92
+ throw new Error('State token is not bound to this client');
93
+ }
94
+ }
95
+ }
@@ -1,3 +1,10 @@
1
1
  declare module 'node:crypto' {
2
+ interface Hash {
3
+ update(data: string): Hash;
4
+ digest(encoding: 'hex'): string;
5
+ }
6
+
2
7
  export function randomUUID(): string;
8
+ export function randomBytes(size: number): { toString(encoding: 'base64url' | 'hex'): string };
9
+ export function createHash(algorithm: string): Hash;
3
10
  }
@@ -3,6 +3,8 @@ declare module 'stonyx/config' {
3
3
  providers: Record<string, { module?: string; [key: string]: unknown }>;
4
4
  sessionDuration: number;
5
5
  frontendCallbackUrl?: string;
6
+ logColor?: string;
7
+ logMethod?: string;
6
8
  }
7
9
  interface Config {
8
10
  oauth: OAuthConfig;
@@ -14,7 +16,13 @@ declare module 'stonyx/config' {
14
16
  }
15
17
 
16
18
  declare module 'stonyx/log' {
17
- const log: Record<string, ((...args: unknown[]) => void) | undefined>;
19
+ interface Log {
20
+ oauth(message: string): void;
21
+ error(message: string): void;
22
+ defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
23
+ [key: string]: unknown;
24
+ }
25
+ const log: Log;
18
26
  export default log;
19
27
  }
20
28