@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/src/main.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import config from 'stonyx/config';
3
+ import log from 'stonyx/log';
4
+ import { waitForModule } from 'stonyx';
5
+ import { setup, emit } from '@stonyx/events';
6
+ import RestServer from '@stonyx/rest-server';
7
+ import TokenManager from './token-manager.js';
8
+ import SessionManager from './session-manager.js';
9
+ import TicketStore from './ticket-store.js';
10
+ import AuthRequest from './auth-request.js';
11
+ import type { RedeemedTicket } from './ticket-store.js';
12
+ import type { SessionResult } from './session-manager.js';
13
+ import type OAuthFlow from './oauth-flow.js';
14
+
15
+ setup(['authenticate']);
16
+
17
+ /** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
18
+ export const STATE_TTL_MS = 10 * 60 * 1000;
19
+
20
+ /** Entropy of the client-held binding value, in bytes. */
21
+ export const BINDING_VALUE_BYTES = 32;
22
+
23
+ interface ProviderEntry {
24
+ flow: OAuthFlow;
25
+ tokenManager: TokenManager;
26
+ }
27
+
28
+ /**
29
+ * A flow that is in progress.
30
+ *
31
+ * Holds a *digest* of the binding value rather than the value itself: a
32
+ * callback is only accepted when the caller presents the plaintext that hashes
33
+ * to `bindingHash`, so the record on its own unlocks nothing.
34
+ */
35
+ export interface PendingState {
36
+ bindingHash: string;
37
+ createdAt: number;
38
+ }
39
+
40
+ export interface IssuedState {
41
+ /** Sent to the provider as the OAuth2 `state` parameter. */
42
+ url: string;
43
+ /** Retained so a login that cannot be bound can withdraw its own state. */
44
+ stateToken: string;
45
+ /** Held by the client that started the flow, never by the provider. */
46
+ bindingValue: string;
47
+ }
48
+
49
+ interface ProviderConfig {
50
+ module?: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ export default class OAuth {
55
+ static instance: OAuth | null;
56
+
57
+ providers = new Map<string, ProviderEntry>();
58
+ pendingStates = new Map<string, PendingState>();
59
+ stateTtl = STATE_TTL_MS;
60
+ sessionManager!: SessionManager;
61
+ ticketStore = new TicketStore();
62
+ frontendCallbackUrl?: string;
63
+
64
+ constructor() {
65
+ if (OAuth.instance) return OAuth.instance;
66
+ OAuth.instance = this;
67
+ }
68
+
69
+ async init(): Promise<void> {
70
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
71
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
72
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
73
+ log.defineType(logMethod, logColor);
74
+
75
+ const oauthConfig = config.oauth;
76
+ const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
77
+ this.frontendCallbackUrl = frontendCallbackUrl;
78
+
79
+ for (const [name, providerConfig] of Object.entries(providers)) {
80
+ const modulePath = providerConfig.module
81
+ ? `${config.rootPath}/${providerConfig.module}`
82
+ : `./providers/${name}.js`;
83
+ const { default: Provider } = await import(modulePath);
84
+ const flow: OAuthFlow = new Provider(providerConfig);
85
+ this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
86
+ }
87
+
88
+ this.sessionManager = new SessionManager(sessionDuration);
89
+
90
+ await waitForModule('rest-server');
91
+ RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
92
+
93
+ log.oauth?.('OAuth module initialized');
94
+ }
95
+
96
+ getProvider(name: string): ProviderEntry {
97
+ const provider = this.providers.get(name);
98
+ if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
99
+ return provider;
100
+ }
101
+
102
+ /**
103
+ * SHA-256 of a binding value, hex encoded.
104
+ *
105
+ * The pending record stores the digest so that read access to the map does
106
+ * not hand over the value a callback must present.
107
+ */
108
+ static hash(value: string): string {
109
+ return createHash('sha256').update(value).digest('hex');
110
+ }
111
+
112
+ /** Length-independent, content-constant-time comparison of two digests. */
113
+ static digestsMatch(a: string, b: string): boolean {
114
+ if (a.length !== b.length) return false;
115
+
116
+ let difference = 0;
117
+ for (let index = 0; index < a.length; index++) {
118
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
119
+ }
120
+
121
+ return difference === 0;
122
+ }
123
+
124
+ /**
125
+ * Whether *any* presented value is the binding value for this record.
126
+ *
127
+ * Every candidate is tried, and the callback is accepted if one matches.
128
+ * Stopping at the first value carrying the cookie's name instead makes a
129
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
130
+ * section 5.4 orders the `Cookie` header by path length then creation time,
131
+ * so an attacker with content control on a sibling subdomain sets a
132
+ * same-named cookie once and every subsequent callback for that victim reads
133
+ * theirs, fails the binding check, and burns the state on the way out. The
134
+ * victim cannot recover by retrying.
135
+ *
136
+ * Accepting any match gives an attacker nothing: they would have to present
137
+ * the victim's own binding value, which is the property being checked. And
138
+ * the candidate list is deliberately uncapped — a cap does not bound an
139
+ * attack, it *is* one, reinstating that denial above its own threshold
140
+ * because the planted cookies are the ones that sort first. The work is
141
+ * already bounded by Node's 16 KB header limit.
142
+ *
143
+ * The reduce does not short-circuit, so the work is a function of how many
144
+ * values were presented and not of which one matched.
145
+ */
146
+ static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean {
147
+ return candidates.reduce(
148
+ (matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched,
149
+ false,
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Starts a flow: an OAuth2 `state` for the provider, and a binding value for
155
+ * the client that asked for it.
156
+ *
157
+ * `state` on its own is replay-window limiting, not the CSRF binding it
158
+ * exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
159
+ * state issued to any visitor validated for any callback, so an attacker
160
+ * could harvest their own state and code, deliver them to a victim over a
161
+ * plain link, and log the victim into the attacker's account. The binding
162
+ * value is the thing the victim's browser carries and the attacker's does
163
+ * not (#36).
164
+ */
165
+ getAuthorizationUrl(providerName: string): IssuedState {
166
+ const { flow } = this.getProvider(providerName);
167
+ const stateToken = randomUUID();
168
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
169
+
170
+ this.pendingStates.set(stateToken, {
171
+ bindingHash: OAuth.hash(bindingValue),
172
+ createdAt: Date.now(),
173
+ });
174
+
175
+ return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
176
+ }
177
+
178
+ /**
179
+ * Withdraws a state that was issued but could not be handed to a client.
180
+ *
181
+ * Used by the login route when the binding cookie cannot be set: a state the
182
+ * client cannot be bound to is exactly the defect this mechanism exists to
183
+ * prevent, so it must not outlive the request that failed to bind it.
184
+ */
185
+ discardState(stateToken: string): void {
186
+ this.pendingStates.delete(stateToken);
187
+ }
188
+
189
+ /**
190
+ * Validates and consumes a pending state, then completes the flow.
191
+ *
192
+ * `bindingValues` is every value the client presented under the binding
193
+ * cookie's name — see `anyCandidateMatches`.
194
+ */
195
+ async handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]) {
196
+ const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
197
+ if (!record) throw new Error('Invalid or missing state token');
198
+
199
+ // Consumed on recognition, before the TTL and binding checks, so every
200
+ // state gets exactly one attempt whatever the outcome. Checking the
201
+ // binding first would leave the record in place on a mismatch and turn
202
+ // this endpoint into a repeatable, unauthenticated oracle against the
203
+ // binding value for the state's full lifetime.
204
+ this.pendingStates.delete(stateToken);
205
+
206
+ if (Date.now() - record.createdAt > this.stateTtl) {
207
+ throw new Error('State token has expired');
208
+ }
209
+
210
+ // No "absent means skip". An empty candidate list is a rejection, which is
211
+ // what makes an attacker-delivered link fail for a victim who never
212
+ // started the flow and therefore holds no binding cookie.
213
+ const candidates = bindingValues.filter(value => value.length > 0);
214
+ if (candidates.length === 0) throw new Error('Missing state binding value');
215
+
216
+ if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
217
+ throw new Error('State token is not bound to this client');
218
+ }
219
+
220
+ // Everything below burns a live authorization code, so the binding is
221
+ // settled before `exchangeCode` is ever reached.
222
+ const { flow, tokenManager } = this.getProvider(providerName);
223
+ const tokens = await tokenManager.getTokens(code);
224
+ const rawUser = await flow.fetchUserInfo(tokens.accessToken);
225
+ const user = flow.normalizeUser(rawUser);
226
+ await emit('authenticate', user);
227
+ return this.sessionManager.create(user, tokens);
228
+ }
229
+
230
+ /** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
231
+ redirectUriFor(providerName: string): string | undefined {
232
+ return this.providers.get(providerName)?.flow.redirectUri;
233
+ }
234
+
235
+ /**
236
+ * Mints the value the callback redirect is allowed to put in a URL (#45).
237
+ *
238
+ * The session id never travels in the redirect. What travels is a ticket
239
+ * that is single-use, expires in 60 seconds, and authenticates nothing on
240
+ * its own — `GET /auth` validates against `sessionManager`, which has never
241
+ * heard of it.
242
+ */
243
+ issueExchangeTicket(session: SessionResult): string {
244
+ return this.ticketStore.issue(session.sessionId, session.expiresAt);
245
+ }
246
+
247
+ /** Spends a ticket for the session id it stands for, or `null`. */
248
+ redeemExchangeTicket(ticket: string): RedeemedTicket | null {
249
+ return this.ticketStore.redeem(ticket);
250
+ }
251
+
252
+ getSession(sessionId: string) {
253
+ return this.sessionManager.validate(sessionId);
254
+ }
255
+
256
+ logout(sessionId: string): void {
257
+ this.sessionManager.destroy(sessionId);
258
+ }
259
+ }
@@ -1,5 +1,29 @@
1
+ export interface OAuthConfig {
2
+ clientId: string;
3
+ clientSecret: string;
4
+ redirectUri: string;
5
+ scopes?: string[];
6
+ authorizationUrl: string;
7
+ tokenUrl: string;
8
+ userInfoUrl: string;
9
+ }
10
+
11
+ export interface TokenResult {
12
+ accessToken: string;
13
+ refreshToken: string | null;
14
+ expiresIn: number;
15
+ }
16
+
1
17
  export default class OAuthFlow {
2
- constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }) {
18
+ clientId: string;
19
+ clientSecret: string;
20
+ redirectUri: string;
21
+ scopes: string[];
22
+ authorizationUrl: string;
23
+ tokenUrl: string;
24
+ userInfoUrl: string;
25
+
26
+ constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }: OAuthConfig) {
3
27
  this.clientId = clientId;
4
28
  this.clientSecret = clientSecret;
5
29
  this.redirectUri = redirectUri;
@@ -9,7 +33,7 @@ export default class OAuthFlow {
9
33
  this.userInfoUrl = userInfoUrl;
10
34
  }
11
35
 
12
- buildAuthorizationUrl(stateToken) {
36
+ buildAuthorizationUrl(stateToken: string): string {
13
37
  const params = new URLSearchParams({
14
38
  client_id: this.clientId,
15
39
  redirect_uri: this.redirectUri,
@@ -21,7 +45,7 @@ export default class OAuthFlow {
21
45
  return `${this.authorizationUrl}?${params.toString()}`;
22
46
  }
23
47
 
24
- async exchangeCode(code) {
48
+ async exchangeCode(code: string): Promise<TokenResult> {
25
49
  const response = await fetch(this.tokenUrl, {
26
50
  method: 'POST',
27
51
  headers: { 'Content-Type': 'application/json' },
@@ -45,7 +69,7 @@ export default class OAuthFlow {
45
69
  };
46
70
  }
47
71
 
48
- async refreshAccessToken(refreshToken) {
72
+ async refreshAccessToken(refreshToken: string): Promise<TokenResult> {
49
73
  const response = await fetch(this.tokenUrl, {
50
74
  method: 'POST',
51
75
  headers: { 'Content-Type': 'application/json' },
@@ -68,7 +92,7 @@ export default class OAuthFlow {
68
92
  };
69
93
  }
70
94
 
71
- async fetchUserInfo(accessToken) {
95
+ async fetchUserInfo(accessToken: string): Promise<unknown> {
72
96
  const response = await fetch(this.userInfoUrl, {
73
97
  headers: { Authorization: `Bearer ${accessToken}` },
74
98
  });
@@ -78,11 +102,11 @@ export default class OAuthFlow {
78
102
  return response.json();
79
103
  }
80
104
 
81
- normalizeUser(rawUser) {
105
+ normalizeUser(rawUser: unknown): unknown {
82
106
  return { raw: rawUser };
83
107
  }
84
108
 
85
- async revokeToken(_accessToken) {
109
+ async revokeToken(_accessToken: string): Promise<void> {
86
110
  // Optional — providers override if supported
87
111
  }
88
112
  }
@@ -1,7 +1,33 @@
1
1
  import OAuthFlow from '../oauth-flow.js';
2
+ import type { TokenResult } from '../oauth-flow.js';
3
+
4
+ interface DiscordProviderConfig {
5
+ clientId: string;
6
+ clientSecret: string;
7
+ redirectUri: string;
8
+ scopes?: string[];
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ interface DiscordUser {
13
+ id: string;
14
+ username: string;
15
+ global_name?: string;
16
+ avatar: string | null;
17
+ email?: string | null;
18
+ }
19
+
20
+ interface NormalizedDiscordUser {
21
+ id: string;
22
+ username: string;
23
+ displayName: string;
24
+ avatar: string | null;
25
+ email: string | null;
26
+ raw: DiscordUser;
27
+ }
2
28
 
3
29
  export default class DiscordProvider extends OAuthFlow {
4
- constructor(config) {
30
+ constructor(config: DiscordProviderConfig) {
5
31
  super({
6
32
  ...config,
7
33
  authorizationUrl: 'https://discord.com/oauth2/authorize',
@@ -10,7 +36,7 @@ export default class DiscordProvider extends OAuthFlow {
10
36
  });
11
37
  }
12
38
 
13
- async exchangeCode(code) {
39
+ async exchangeCode(code: string): Promise<TokenResult> {
14
40
  const response = await fetch(this.tokenUrl, {
15
41
  method: 'POST',
16
42
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -34,7 +60,7 @@ export default class DiscordProvider extends OAuthFlow {
34
60
  };
35
61
  }
36
62
 
37
- normalizeUser(rawUser) {
63
+ override normalizeUser(rawUser: DiscordUser): NormalizedDiscordUser {
38
64
  const { id, username, global_name, avatar, email } = rawUser;
39
65
 
40
66
  return {
@@ -1,13 +1,26 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ interface SessionData {
4
+ user: unknown;
5
+ tokens: unknown;
6
+ expiresAt: number;
7
+ }
8
+
9
+ export interface SessionResult {
10
+ sessionId: string;
11
+ user: unknown;
12
+ expiresAt: number;
13
+ }
14
+
3
15
  export default class SessionManager {
4
- sessions = new Map();
16
+ sessions = new Map<string, SessionData>();
17
+ duration: number;
5
18
 
6
- constructor(duration) {
19
+ constructor(duration: number) {
7
20
  this.duration = duration;
8
21
  }
9
22
 
10
- create(user, tokens) {
23
+ create(user: unknown, tokens: unknown): SessionResult {
11
24
  const sessionId = randomUUID();
12
25
  const expiresAt = Date.now() + (this.duration * 1000);
13
26
 
@@ -16,15 +29,15 @@ export default class SessionManager {
16
29
  return { sessionId, user, expiresAt };
17
30
  }
18
31
 
19
- get(sessionId) {
32
+ get(sessionId: string): SessionData | null {
20
33
  return this.sessions.get(sessionId) || null;
21
34
  }
22
35
 
23
- destroy(sessionId) {
36
+ destroy(sessionId: string): void {
24
37
  this.sessions.delete(sessionId);
25
38
  }
26
39
 
27
- validate(sessionId) {
40
+ validate(sessionId: string): unknown {
28
41
  const session = this.get(sessionId);
29
42
  if (!session) return null;
30
43
 
@@ -0,0 +1,123 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ /**
4
+ * Lifetime of an exchange ticket.
5
+ *
6
+ * Sized for one redirect plus one page load, and deliberately two orders of
7
+ * magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
8
+ * travelling in a URL, and the whole point of #45 is that a bearer value in a
9
+ * URL must not be long-lived — in the fragment, so it reaches no server, but
10
+ * still into browser history and readable by scripts on the landing page.
11
+ */
12
+ export const TICKET_TTL_MS = 60 * 1000;
13
+
14
+ /** Entropy of a ticket, in bytes. */
15
+ export const TICKET_BYTES = 32;
16
+
17
+ interface TicketRecord {
18
+ sessionId: string;
19
+ expiresAt: number;
20
+ createdAt: number;
21
+ }
22
+
23
+ export interface RedeemedTicket {
24
+ sessionId: string;
25
+ expiresAt: number;
26
+ }
27
+
28
+ /**
29
+ * Single-use, short-lived tickets that stand in for a session id on the wire.
30
+ *
31
+ * The callback redirect hands the browser a ticket instead of the session id
32
+ * (#45), in the URL *fragment*, which no user agent transmits to any server.
33
+ * The ticket authenticates nothing — `GET /auth` reads the `session-id` header
34
+ * and knows only about `SessionManager` — so a ticket observed in history or
35
+ * by a script reading `location.hash` is worth something only inside the
36
+ * sub-second window before the landing page redeems it, and nothing at all
37
+ * afterwards.
38
+ *
39
+ * Known residual, stated rather than papered over: a ticket observed *within*
40
+ * that window is redeemable by the observer, because nothing here binds a
41
+ * ticket to the client that started the flow. Closing it means binding the way
42
+ * #36 bound the state, and that binding has to travel on a cookie the
43
+ * cross-origin exchange cannot carry.
44
+ *
45
+ * The blocker is `abofs/stonyx-rest-server#63`: `@stonyx/rest-server` calls
46
+ * `cors({ origin, methods })` and has no `credentials` support at all. It is
47
+ * *not* `abofs/stonyx-rest-server#45` — that issue is the response-header half
48
+ * and is already worked around in `auth-request.ts`, which sets and clears the
49
+ * binding cookie on a redirect by reaching through `req.res`. Closing #45
50
+ * would not make this residual closeable. It is a reduction, not an
51
+ * elimination.
52
+ *
53
+ * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
54
+ * a pre-existing pattern in this module, not something this store introduces,
55
+ * and it is bounded here by a 60-second TTL rather than a 10-minute one.
56
+ * Tracked, with both maps named, at `abofs/stonyx-oauth#43`.
57
+ *
58
+ * ---
59
+ *
60
+ * **Why this is a second store rather than a reuse of `OAuth.pendingStates`.**
61
+ *
62
+ * The duplication is real and is not an oversight: `pendingStates` is also a
63
+ * single-use, TTL-bounded, consume-on-recognition map keyed by a
64
+ * `randomBytes`-minted opaque token, with the same delete-before-TTL-check
65
+ * ordering and the same never-collected caveat. The shared shape could be
66
+ * extracted into one primitive, and the two constants homes (`STATE_TTL_MS`
67
+ * and `BINDING_VALUE_BYTES` in `main.ts`, `TICKET_TTL_MS` and `TICKET_BYTES`
68
+ * here) could then live together.
69
+ *
70
+ * It is deliberately not done in the change that fixes #45. Widening a
71
+ * security fix into a refactor of the CSRF store means the #36 binding
72
+ * mechanism — whose invariants are load-bearing and separately guarded — moves
73
+ * in the same commit as the fix, for no security gain in either. The two also
74
+ * do not have the same invariants: `pendingStates` is a security control fed
75
+ * by an unauthenticated `GET`, holding a *digest* of a client secret, with a
76
+ * 10-minute budget sized for a provider round trip; this is a delivery
77
+ * convenience reachable only after a successfully bound callback, holding a
78
+ * value it hands back, with a 60-second budget sized for a page load.
79
+ * Collapsing them would couple the control to the convenience.
80
+ *
81
+ * The extraction is tracked at `abofs/stonyx-oauth#58`.
82
+ */
83
+ export default class TicketStore {
84
+ tickets = new Map<string, TicketRecord>();
85
+ ttl = TICKET_TTL_MS;
86
+
87
+ /**
88
+ * Mints a ticket for a freshly created session.
89
+ *
90
+ * The ticket is independent entropy, never a transform of the session id:
91
+ * anything derived from the credential is the credential.
92
+ */
93
+ issue(sessionId: string, expiresAt: number): string {
94
+ const ticket = randomBytes(TICKET_BYTES).toString('base64url');
95
+ this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
96
+ return ticket;
97
+ }
98
+
99
+ /**
100
+ * Spends a ticket, if it is live.
101
+ *
102
+ * Consumed on recognition, *before* the TTL check, for the same reason
103
+ * `OAuth.handleCallback` consumes a pending state before validating its
104
+ * binding: every ticket gets exactly one attempt whatever the outcome, so
105
+ * this endpoint is never a repeatable oracle. Deleting after the TTL check
106
+ * instead would leave an expired ticket in the map answering `400` forever
107
+ * while a live one answers `200` — an unauthenticated distinguisher.
108
+ *
109
+ * Returns `null` for unknown, spent and expired tickets alike. The caller
110
+ * maps all three to the same `400`; telling them apart is information the
111
+ * holder of a ticket they did not mint has no business having.
112
+ */
113
+ redeem(ticket: string): RedeemedTicket | null {
114
+ const record = ticket ? this.tickets.get(ticket) : undefined;
115
+ if (!record) return null;
116
+
117
+ this.tickets.delete(ticket);
118
+
119
+ if (Date.now() - record.createdAt > this.ttl) return null;
120
+
121
+ return { sessionId: record.sessionId, expiresAt: record.expiresAt };
122
+ }
123
+ }
@@ -0,0 +1,35 @@
1
+ import type OAuthFlow from './oauth-flow.js';
2
+ import type { TokenResult } from './oauth-flow.js';
3
+
4
+ export interface TokenData extends TokenResult {
5
+ expiresAt: number;
6
+ }
7
+
8
+ export default class TokenManager {
9
+ flow: OAuthFlow;
10
+
11
+ constructor(flow: OAuthFlow) {
12
+ this.flow = flow;
13
+ }
14
+
15
+ async getTokens(code: string): Promise<TokenData> {
16
+ const tokens = await this.flow.exchangeCode(code) as TokenData;
17
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
18
+ return tokens;
19
+ }
20
+
21
+ async refresh(refreshToken: string): Promise<TokenData> {
22
+ const tokens = await this.flow.refreshAccessToken(refreshToken) as TokenData;
23
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
24
+ return tokens;
25
+ }
26
+
27
+ async revoke(accessToken: string): Promise<void> {
28
+ return this.flow.revokeToken(accessToken);
29
+ }
30
+
31
+ isExpired(tokenData: { expiresAt?: number } | null | undefined): boolean {
32
+ if (!tokenData?.expiresAt) return true;
33
+ return Date.now() >= tokenData.expiresAt;
34
+ }
35
+ }
@@ -0,0 +1,19 @@
1
+ declare module 'node:crypto' {
2
+ export function randomUUID(): string;
3
+
4
+ /**
5
+ * Structural stand-ins: this repo declares its own node shims rather than
6
+ * depending on `@types/node`, so only the surface actually used is typed.
7
+ */
8
+ interface BinaryLike {
9
+ toString(encoding: string): string;
10
+ }
11
+
12
+ interface Hash {
13
+ update(data: string): Hash;
14
+ digest(encoding: string): string;
15
+ }
16
+
17
+ export function randomBytes(size: number): BinaryLike;
18
+ export function createHash(algorithm: string): Hash;
19
+ }
@@ -0,0 +1,4 @@
1
+ declare module '@stonyx/events' {
2
+ export function setup(events: string[]): void;
3
+ export function emit(event: string, ...args: unknown[]): Promise<void>;
4
+ }
@@ -0,0 +1,11 @@
1
+ declare module '@stonyx/rest-server' {
2
+ export class Request {
3
+ constructor();
4
+ }
5
+
6
+ export default class RestServer {
7
+ static instance: RestServer;
8
+ static close(): void;
9
+ mountRoute(RequestClass: unknown, options: { name: string; options?: unknown }): void;
10
+ }
11
+ }
@@ -0,0 +1,38 @@
1
+ declare module 'stonyx/config' {
2
+ interface OAuthConfig {
3
+ providers: Record<string, { module?: string; [key: string]: unknown }>;
4
+ sessionDuration: number;
5
+ frontendCallbackUrl?: string;
6
+ logColor?: string;
7
+ logMethod?: string;
8
+ }
9
+ interface Config {
10
+ oauth: OAuthConfig;
11
+ rootPath: string;
12
+ [key: string]: unknown;
13
+ }
14
+ const config: Config;
15
+ export default config;
16
+ }
17
+
18
+ declare module 'stonyx/log' {
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;
26
+ export default log;
27
+ }
28
+
29
+ declare module 'stonyx' {
30
+ export function waitForModule(name: string): Promise<void>;
31
+ }
32
+
33
+ declare module 'stonyx/test-helpers' {
34
+ export function setupIntegrationTests(hooks: {
35
+ before(fn: () => void | Promise<void>): void;
36
+ after(fn: () => void | Promise<void>): void;
37
+ }): void;
38
+ }