@stonyx/oauth 0.1.1-alpha.21 → 0.1.1-alpha.23

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.
@@ -1,52 +1,24 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
- import { StateRejection } from './state-store.js';
4
- import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
5
3
  /**
6
- * Hosts treated as a development origin by exact match, and together with
7
- * `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
8
- * from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
4
+ * The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
9
5
  *
10
- * `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
11
- * local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
12
- * listed here, so the two are not silently redundant.
13
- */
14
- const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
15
- /** `host` values whose port component is anything but a decimal port are rejected. */
16
- const PORT_PATTERN = /^\d{1,5}$/;
17
- /**
18
- * The characters RFC 1123 permits in a registered hostname, plus `.`.
6
+ * The attributes below are load-bearing, not cosmetic:
19
7
  *
20
- * Anything else `@`, `,`, whitespace, `/` means the value is not a bare
21
- * hostname, and the caller fails secure rather than guessing. This is what
22
- * rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
23
- */
24
- const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
25
- /** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
26
- function isLoopbackIpv4(hostname) {
27
- const octets = hostname.split('.');
28
- if (octets.length !== 4)
29
- return false;
30
- if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255))
31
- return false;
32
- return Number(octets[0]) === 127;
33
- }
34
- /**
35
- * IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
36
- * `::ffff:127.0.0.1` and `::ffff:7f00:1`.
8
+ * - `SameSite=Lax`the callback is a cross-site, top-level GET navigation
9
+ * initiated by the provider. `Strict` withholds the cookie on exactly that
10
+ * request, breaking 100% of logins while passing every CSRF test; `None`
11
+ * requires `Secure` and widens exposure for no benefit.
12
+ * - `Path=/` — routing is case-insensitive today
13
+ * (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
14
+ * RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
15
+ * `/auth` silently drops the cookie on a case-varied callback and breaks
16
+ * login.
17
+ * - `HttpOnly` — script must not be able to read or forge the binding value.
37
18
  */
38
- function isLoopbackIpv6(hostname) {
39
- const mapped = /^::ffff:(.+)$/.exec(hostname);
40
- if (!mapped)
41
- return false;
42
- const rest = mapped[1];
43
- if (isLoopbackIpv4(rest))
44
- return true;
45
- const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
46
- if (!hextets)
47
- return false;
48
- return parseInt(hextets[1], 16) >>> 8 === 127;
49
- }
19
+ const STATE_COOKIE_NAME = 'oauth_state';
20
+ const STATE_COOKIE_PATH = '/';
21
+ const STATE_COOKIE_SAME_SITE = 'lax';
50
22
  export default class AuthRequest extends Request {
51
23
  oauth;
52
24
  constructor(oauth) {
@@ -73,16 +45,18 @@ export default class AuthRequest extends Request {
73
45
  catch {
74
46
  return 404;
75
47
  }
76
- // Fail closed: a state we cannot bind to this client is exactly the
77
- // defect this mechanism exists to prevent, so never issue one.
78
- if (!this.setBindingCookie(req, authorization.bindingValue))
48
+ // Fail closed. A state we cannot bind to this client is exactly the
49
+ // defect this mechanism exists to prevent, so it is withdrawn rather
50
+ // than issued unbindable.
51
+ if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
52
+ this.oauth.discardState(authorization.stateToken);
79
53
  return 500;
54
+ }
80
55
  state.redirect = authorization.url;
81
56
  },
82
57
  '/callback/:provider': async (req, state) => {
83
58
  const { provider: providerName } = req.params;
84
59
  const { code, state: stateToken, error } = req.query;
85
- const bindingValues = this.readBindingCookies(req);
86
60
  if (error) {
87
61
  if (this.oauth.frontendCallbackUrl) {
88
62
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -93,10 +67,17 @@ export default class AuthRequest extends Request {
93
67
  if (!code)
94
68
  return 400;
95
69
  try {
96
- const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValues);
97
- // The binding value is single-use and the state has now been
98
- // consumed, so this is the end of that cookie's life.
99
- this.clearBindingCookie(req);
70
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, this.readBindingCookies(req));
71
+ // Cleared only here, on the success path, which is the only path that
72
+ // is certain to have consumed a state belonging to *this* client.
73
+ //
74
+ // Clearing on failure instead looks harmless and is not: `code` is
75
+ // attacker-supplied and unvalidated, so a bare `?code=1` — no
76
+ // knowledge of anyone's state — would delete the binding cookie of a
77
+ // client still sitting on the provider's consent screen, leaving
78
+ // their pending state untouched so nothing is detectable
79
+ // server-side, and their real callback then fails.
80
+ this.clearBindingCookie(req, providerName);
100
81
  if (this.oauth.frontendCallbackUrl) {
101
82
  const params = new URLSearchParams({
102
83
  sessionId: session.sessionId,
@@ -107,52 +88,7 @@ export default class AuthRequest extends Request {
107
88
  }
108
89
  return session;
109
90
  }
110
- catch (rejection) {
111
- // Clear only when this request actually spent the cookie.
112
- //
113
- // Moving the clear below the `error` and `!code` returns was not
114
- // enough: it still ran unconditionally for any request carrying a
115
- // `code`, and `code` is attacker-supplied and unvalidated. So
116
- // `?code=1` — one query parameter, no knowledge of the victim's state
117
- // — deleted the binding cookie of a client still at the provider's
118
- // consent screen, leaving their pending state untouched so nothing
119
- // was detectable server-side, and their real callback then failed.
120
- //
121
- // `StateRejection.consumed` is the only thing that distinguishes
122
- // "nothing of this client's was touched" from "one attempt was
123
- // spent". Anything that is not a `StateRejection` was thrown below
124
- // the state check, which means the record was already burned.
125
- if (!(rejection instanceof StateRejection) || rejection.consumed) {
126
- this.clearBindingCookie(req);
127
- }
128
- // `StateStore.consume` distinguishes five rejection reasons that
129
- // otherwise collapse into one opaque outcome with no server-side
130
- // signal at all. The client-facing `auth_failed` stays opaque; the
131
- // server has no reason to be.
132
- //
133
- // Only a `StateRejection`'s message is logged, and those are the
134
- // fixed strings in `STATE_REJECTION`. The `try` above spans far more
135
- // than `consume` — `getProvider`, `TokenManager.getTokens` ->
136
- // `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
137
- // `emit('authenticate')`, `sessionManager.create` — and three of
138
- // those are consumer-overridable through the documented
139
- // `providers.<name>.module` extension point. A provider that puts
140
- // request context in its error, which is ordinary practice, would
141
- // otherwise land its `clientSecret` and the caller-supplied `code` in
142
- // the log verbatim; `@stonyx/logs` appends content raw when
143
- // `logToFile` is enabled, so an echoed `code` is also a CRLF
144
- // log-forging primitive for an unauthenticated caller. Before this
145
- // module logged anything, all of that was swallowed.
146
- //
147
- // Anything below the state check therefore gets a fixed
148
- // discriminator, and the detail is left to whatever the provider
149
- // itself logs.
150
- if (rejection instanceof StateRejection) {
151
- log.error(`OAuth: callback rejected — ${rejection.message}`);
152
- }
153
- else {
154
- log.error('OAuth: callback failed after state validation');
155
- }
91
+ catch {
156
92
  if (this.oauth.frontendCallbackUrl) {
157
93
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
158
94
  return;
@@ -167,151 +103,60 @@ export default class AuthRequest extends Request {
167
103
  },
168
104
  }
169
105
  };
170
- cookieOptions(req) {
171
- return {
172
- httpOnly: true,
173
- // Load-bearing: the callback is a cross-site top-level GET navigation
174
- // from the provider. `Strict` withholds the cookie on exactly that
175
- // request and breaks login outright.
176
- sameSite: STATE_COOKIE_SAME_SITE,
177
- path: STATE_COOKIE_PATH,
178
- secure: this.isSecureContext(req),
179
- };
180
- }
181
106
  /**
182
107
  * Whether the binding cookie is issued with `Secure`.
183
108
  *
184
- * Not `req.secure`. Express derives that from the socket unless `trust proxy`
185
- * is enabled, and `@stonyx/rest-server` leaves it off by default
186
- * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
187
- * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
188
- * is therefore `false` on every request to an HTTPS site, and the binding
189
- * cookie would ship without `Secure` while the deployment looks correct.
109
+ * Derived from the scheme of the provider's configured `redirectUri`, which
110
+ * is the deployment's own statement of the origin this cookie has to survive
111
+ * a round trip to.
190
112
  *
191
- * So `Secure` is set unconditionally except on a loopback host. Guessing
192
- * wrong there breaks a non-loopback plaintext development setup, which fails
193
- * at the first login and is loud. The alternative fails silently, in
194
- * production, on the one attribute protecting the value this whole mechanism
195
- * is built around.
113
+ * Not `req.secure`: express derives that from the socket unless `trust proxy`
114
+ * is on, and `@stonyx/rest-server` leaves it off by default, so in the
115
+ * standard production topology TLS terminated at a proxy, plaintext to the
116
+ * origin — `req.secure` is `false` on every request to an HTTPS site and the
117
+ * cookie would ship without `Secure` while the deployment looks correct. Not
118
+ * the `Host` header either: that is attacker-controllable on any non-browser
119
+ * client. And not hardcoded `true`, which breaks plaintext local development.
196
120
  *
197
- * The exemption is decided by *parsing* the `Host` header and testing the
198
- * result for membership, never by matching a prefix or a suffix on the raw
199
- * value — `Host` is attacker-controllable on any non-browser client, and a
200
- * security predicate written as a substring match drifts. Every shape that
201
- * cannot be parsed as a bare `host[:port]`, and every request with more than
202
- * one `Host`, fails secure.
121
+ * An unparseable or absent redirect URI fails secure.
203
122
  */
204
- isSecureContext(req) {
205
- if (req.secure === true)
206
- return true;
207
- if (AuthRequest.hasAmbiguousHost(req))
123
+ isSecureContext(providerName) {
124
+ const redirectUri = this.oauth.redirectUriFor(providerName);
125
+ if (!redirectUri)
208
126
  return true;
209
- const host = req.headers.host;
210
- if (!host)
211
- return true;
212
- const hostname = AuthRequest.parseHostname(host);
213
- if (hostname === undefined)
214
- return true;
215
- return !AuthRequest.isLoopbackHost(hostname);
216
- }
217
- /**
218
- * True when the request carried more than one `Host` header.
219
- *
220
- * Node keeps the first and discards the rest, so a component that *prepends*
221
- * a `Host:` line — request smuggling, or a proxy that appends rather than
222
- * replaces — can make `req.headers.host` read `localhost` on a request whose
223
- * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
224
- * this treats it as unattributable and fails secure rather than trusting it.
225
- */
226
- static hasAmbiguousHost(req) {
227
- const raw = req.rawHeaders;
228
- if (!Array.isArray(raw))
229
- return false;
230
- let seen = 0;
231
- for (let index = 0; index < raw.length; index += 2) {
232
- if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host')
233
- seen++;
127
+ try {
128
+ return new URL(redirectUri).protocol !== 'http:';
234
129
  }
235
- return seen > 1;
236
- }
237
- /**
238
- * The hostname component of a `Host` header, lowercased, or `undefined` when
239
- * the value is not a well-formed `host[:port]`.
240
- *
241
- * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
242
- * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
243
- * required to be decimal, and the hostname to contain only characters a
244
- * registered name may contain.
245
- */
246
- static parseHostname(host) {
247
- if (host.startsWith('[')) {
248
- const close = host.indexOf(']');
249
- if (close === -1)
250
- return undefined;
251
- const port = host.slice(close + 1);
252
- if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1))))
253
- return undefined;
254
- const literal = host.slice(1, close);
255
- if (!/^[0-9A-Fa-f:.]+$/.test(literal))
256
- return undefined;
257
- return literal.toLowerCase();
130
+ catch {
131
+ return true;
258
132
  }
259
- const colon = host.indexOf(':');
260
- if (colon === -1)
261
- return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
262
- if (!PORT_PATTERN.test(host.slice(colon + 1)))
263
- return undefined;
264
- const name = host.slice(0, colon);
265
- return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
266
133
  }
267
- /**
268
- * Whether a parsed hostname is a loopback development origin.
269
- *
270
- * Membership tests, never prefix or suffix tests. `startsWith('127.')`
271
- * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
272
- * leading digit in a label), and `endsWith('.localhost')` exempted an entire
273
- * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
274
- * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
275
- * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
276
- * never documented it, and a developer on `app.localhost` reaches the same
277
- * server on `localhost` or `127.0.0.1`.
278
- */
279
- static isLoopbackHost(hostname) {
280
- if (LOOPBACK_HOSTS.has(hostname))
281
- return true;
282
- if (isLoopbackIpv4(hostname))
283
- return true;
284
- return isLoopbackIpv6(hostname);
134
+ cookieOptions(providerName) {
135
+ return {
136
+ httpOnly: true,
137
+ sameSite: STATE_COOKIE_SAME_SITE,
138
+ path: STATE_COOKIE_PATH,
139
+ secure: this.isSecureContext(providerName),
140
+ };
285
141
  }
286
- setBindingCookie(req, bindingValue) {
142
+ setBindingCookie(req, providerName, bindingValue) {
287
143
  const { res } = req;
288
144
  if (typeof res?.cookie !== 'function') {
289
145
  log.error('OAuth: unable to set the state binding cookie; login rejected');
290
146
  return false;
291
147
  }
292
148
  res.cookie(STATE_COOKIE_NAME, bindingValue, {
293
- ...this.cookieOptions(req),
294
- maxAge: STATE_TTL_MS,
149
+ ...this.cookieOptions(providerName),
150
+ maxAge: this.oauth.stateTtl,
295
151
  });
296
152
  return true;
297
153
  }
298
154
  /**
299
155
  * Every value the client presented under the binding cookie's name.
300
156
  *
301
- * Not the first one. A browser sends every applicable cookie in a single
302
- * header, and a sibling subdomain can set a same-named cookie on the parent
303
- * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
304
- * returning on the first name match handed an attacker a permanent,
305
- * unauthenticated denial of login for any victim they could plant a cookie
306
- * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
307
- * is writing, not reading.
308
- *
309
- * Every value is returned, with no cap. A cap here does not bound an attack,
310
- * it *is* one: truncating the list reinstates exactly the denial above its
311
- * own threshold, because the planted cookies are the ones that sort first.
312
- * The work is already bounded by Node's 16 KB header limit — at most 779
313
- * hashable candidates, 0.32 ms to parse and hash all of them. See
314
- * `constants.ts` for the measurement.
157
+ * Not the first one, and not capped see `OAuth.anyCandidateMatches` for why
158
+ * either would hand an attacker a permanent, unauthenticated denial of login
159
+ * for any victim they can plant a same-named cookie on.
315
160
  */
316
161
  readBindingCookies(req) {
317
162
  const header = req.headers.cookie;
@@ -325,18 +170,18 @@ export default class AuthRequest extends Request {
325
170
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
326
171
  continue;
327
172
  // Not decoded. The binding value is base64url, whose alphabet
328
- // `encodeURIComponent` never escapes, so a decode buys nothing — and
173
+ // `encodeURIComponent` never escapes, so decoding buys nothing — and
329
174
  // `decodeURIComponent` throws `URIError` on malformed input, which any
330
175
  // unauthenticated caller can supply, turning the first line of the
331
- // callback into a 500 with a stack trace.
176
+ // callback into a 500.
332
177
  values.push(part.slice(separator + 1).trim());
333
178
  }
334
179
  return values;
335
180
  }
336
- clearBindingCookie(req) {
181
+ clearBindingCookie(req, providerName) {
337
182
  const { res } = req;
338
183
  if (typeof res?.clearCookie !== 'function')
339
184
  return;
340
- res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
185
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
341
186
  }
342
187
  }
package/dist/main.d.ts CHANGED
@@ -1,44 +1,105 @@
1
1
  import TokenManager from './token-manager.js';
2
2
  import SessionManager from './session-manager.js';
3
- import StateStore from './state-store.js';
4
3
  import type OAuthFlow from './oauth-flow.js';
4
+ /** Lifetime of a pending state, 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;
5
8
  interface ProviderEntry {
6
9
  flow: OAuthFlow;
7
10
  tokenManager: TokenManager;
8
11
  }
9
- export interface AuthorizationRequest {
10
- /** Provider authorization URL to redirect the client to. */
12
+ /**
13
+ * A flow that is in progress.
14
+ *
15
+ * Holds a *digest* of the binding value rather than the value itself: a
16
+ * callback is only accepted when the caller presents the plaintext that hashes
17
+ * to `bindingHash`, so the record on its own unlocks nothing.
18
+ */
19
+ export interface PendingState {
20
+ bindingHash: string;
21
+ createdAt: number;
22
+ }
23
+ export interface IssuedState {
24
+ /** Sent to the provider as the OAuth2 `state` parameter. */
11
25
  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
- */
26
+ /** Retained so a login that cannot be bound can withdraw its own state. */
27
+ stateToken: string;
28
+ /** Held by the client that started the flow, never by the provider. */
17
29
  bindingValue: string;
18
30
  }
19
31
  export default class OAuth {
20
32
  static instance: OAuth | null;
21
33
  providers: Map<string, ProviderEntry>;
22
- stateStore: StateStore;
34
+ pendingStates: Map<string, PendingState>;
35
+ stateTtl: number;
23
36
  sessionManager: SessionManager;
24
37
  frontendCallbackUrl?: string;
25
38
  constructor();
26
39
  init(): Promise<void>;
27
40
  getProvider(name: string): ProviderEntry;
28
- getAuthorizationUrl(providerName: string): AuthorizationRequest;
29
41
  /**
30
- * `bindingValues` is required, not optional (#36). An optional parameter lets
31
- * an existing three-argument call site keep compiling and then fail at
32
- * runtime on the first real login; a compile error is the loudest disclosure
33
- * channel available for this break.
42
+ * SHA-256 of a binding value, hex encoded.
43
+ *
44
+ * The pending record stores the digest so that read access to the map does
45
+ * not hand over the value a callback must present.
46
+ */
47
+ static hash(value: string): string;
48
+ /** Length-independent, content-constant-time comparison of two digests. */
49
+ static digestsMatch(a: string, b: string): boolean;
50
+ /**
51
+ * Whether *any* presented value is the binding value for this record.
52
+ *
53
+ * Every candidate is tried, and the callback is accepted if one matches.
54
+ * Stopping at the first value carrying the cookie's name instead makes a
55
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
56
+ * section 5.4 orders the `Cookie` header by path length then creation time,
57
+ * so an attacker with content control on a sibling subdomain sets a
58
+ * same-named cookie once and every subsequent callback for that victim reads
59
+ * theirs, fails the binding check, and burns the state on the way out. The
60
+ * victim cannot recover by retrying.
61
+ *
62
+ * Accepting any match gives an attacker nothing: they would have to present
63
+ * the victim's own binding value, which is the property being checked. And
64
+ * the candidate list is deliberately uncapped — a cap does not bound an
65
+ * attack, it *is* one, reinstating that denial above its own threshold
66
+ * because the planted cookies are the ones that sort first. The work is
67
+ * already bounded by Node's 16 KB header limit.
68
+ *
69
+ * The reduce does not short-circuit, so the work is a function of how many
70
+ * values were presented and not of which one matched.
71
+ */
72
+ static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean;
73
+ /**
74
+ * Starts a flow: an OAuth2 `state` for the provider, and a binding value for
75
+ * the client that asked for it.
76
+ *
77
+ * `state` on its own is replay-window limiting, not the CSRF binding it
78
+ * exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
79
+ * state issued to any visitor validated for any callback, so an attacker
80
+ * could harvest their own state and code, deliver them to a victim over a
81
+ * plain link, and log the victim into the attacker's account. The binding
82
+ * value is the thing the victim's browser carries and the attacker's does
83
+ * not (#36).
84
+ */
85
+ getAuthorizationUrl(providerName: string): IssuedState;
86
+ /**
87
+ * Withdraws a state that was issued but could not be handed to a client.
88
+ *
89
+ * Used by the login route when the binding cookie cannot be set: a state the
90
+ * client cannot be bound to is exactly the defect this mechanism exists to
91
+ * prevent, so it must not outlive the request that failed to bind it.
92
+ */
93
+ discardState(stateToken: string): void;
94
+ /**
95
+ * Validates and consumes a pending state, then completes the flow.
34
96
  *
35
- * It is an array, not a single value, because a client can hold more than one
36
- * cookie of the binding cookie's name and every one of them has to be tried —
37
- * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
38
- * passes `[bindingValue]`; the route handler passes through every value the
39
- * client presented, which may be none.
97
+ * `bindingValues` is every value the client presented under the binding
98
+ * cookie's name see `anyCandidateMatches`.
40
99
  */
41
100
  handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<import("./session-manager.js").SessionResult>;
101
+ /** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
102
+ redirectUriFor(providerName: string): string | undefined;
42
103
  getSession(sessionId: string): unknown;
43
104
  logout(sessionId: string): void;
44
105
  }