@stonyx/oauth 0.1.1-beta.2 → 0.1.1-beta.200
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 +193 -2
- package/dist/auth-request.d.ts +147 -0
- package/dist/auth-request.js +247 -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 +134 -0
- package/dist/ticket-store.js +140 -0
- package/dist/token-manager.d.ts +15 -0
- package/dist/token-manager.js +24 -0
- package/package.json +45 -8
- package/src/auth-request.ts +348 -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 +158 -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/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/src/auth-request.js +0 -74
- package/src/main.js +0 -79
- package/src/token-manager.js +0 -26
- package/test/config/environment.js +0 -18
- package/test/integration/oauth-test.js +0 -149
- package/test/sample/providers/mock.js +0 -40
- package/test/sample/requests/.gitkeep +0 -0
- package/test/unit/oauth-flow-test.js +0 -137
- package/test/unit/providers/discord-test.js +0 -115
- package/test/unit/session-manager-test.js +0 -85
- package/test/unit/state-validation-test.js +0 -118
- package/test/unit/token-manager-test.js +0 -76
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import TokenManager from './token-manager.js';
|
|
2
|
+
import SessionManager from './session-manager.js';
|
|
3
|
+
import TicketStore from './ticket-store.js';
|
|
4
|
+
import type { RedeemedTicket } from './ticket-store.js';
|
|
5
|
+
import type { SessionResult } from './session-manager.js';
|
|
6
|
+
import type OAuthFlow from './oauth-flow.js';
|
|
7
|
+
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
8
|
+
export declare const STATE_TTL_MS: number;
|
|
9
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
10
|
+
export declare const BINDING_VALUE_BYTES = 32;
|
|
11
|
+
interface ProviderEntry {
|
|
12
|
+
flow: OAuthFlow;
|
|
13
|
+
tokenManager: TokenManager;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A flow that is in progress.
|
|
17
|
+
*
|
|
18
|
+
* Holds a *digest* of the binding value rather than the value itself: a
|
|
19
|
+
* callback is only accepted when the caller presents the plaintext that hashes
|
|
20
|
+
* to `bindingHash`, so the record on its own unlocks nothing.
|
|
21
|
+
*/
|
|
22
|
+
export interface PendingState {
|
|
23
|
+
bindingHash: string;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}
|
|
26
|
+
export interface IssuedState {
|
|
27
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Retained so a login that cannot be bound can withdraw its own state. */
|
|
30
|
+
stateToken: string;
|
|
31
|
+
/** Held by the client that started the flow, never by the provider. */
|
|
32
|
+
bindingValue: string;
|
|
33
|
+
}
|
|
34
|
+
export default class OAuth {
|
|
35
|
+
static instance: OAuth | null;
|
|
36
|
+
providers: Map<string, ProviderEntry>;
|
|
37
|
+
pendingStates: Map<string, PendingState>;
|
|
38
|
+
stateTtl: number;
|
|
39
|
+
sessionManager: SessionManager;
|
|
40
|
+
ticketStore: TicketStore;
|
|
41
|
+
frontendCallbackUrl?: string;
|
|
42
|
+
constructor();
|
|
43
|
+
init(): Promise<void>;
|
|
44
|
+
getProvider(name: string): ProviderEntry;
|
|
45
|
+
/**
|
|
46
|
+
* SHA-256 of a binding value, hex encoded.
|
|
47
|
+
*
|
|
48
|
+
* The pending record stores the digest so that read access to the map does
|
|
49
|
+
* not hand over the value a callback must present.
|
|
50
|
+
*/
|
|
51
|
+
static hash(value: string): string;
|
|
52
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
53
|
+
static digestsMatch(a: string, b: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Whether *any* presented value is the binding value for this record.
|
|
56
|
+
*
|
|
57
|
+
* Every candidate is tried, and the callback is accepted if one matches.
|
|
58
|
+
* Stopping at the first value carrying the cookie's name instead makes a
|
|
59
|
+
* planted cookie a permanent, unauthenticated denial of login: RFC 6265
|
|
60
|
+
* section 5.4 orders the `Cookie` header by path length then creation time,
|
|
61
|
+
* so an attacker with content control on a sibling subdomain sets a
|
|
62
|
+
* same-named cookie once and every subsequent callback for that victim reads
|
|
63
|
+
* theirs, fails the binding check, and burns the state on the way out. The
|
|
64
|
+
* victim cannot recover by retrying.
|
|
65
|
+
*
|
|
66
|
+
* Accepting any match gives an attacker nothing: they would have to present
|
|
67
|
+
* the victim's own binding value, which is the property being checked. And
|
|
68
|
+
* the candidate list is deliberately uncapped — a cap does not bound an
|
|
69
|
+
* attack, it *is* one, reinstating that denial above its own threshold
|
|
70
|
+
* because the planted cookies are the ones that sort first. The work is
|
|
71
|
+
* already bounded by Node's 16 KB header limit.
|
|
72
|
+
*
|
|
73
|
+
* The reduce does not short-circuit, so the work is a function of how many
|
|
74
|
+
* values were presented and not of which one matched.
|
|
75
|
+
*/
|
|
76
|
+
static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Starts a flow: an OAuth2 `state` for the provider, and a binding value for
|
|
79
|
+
* the client that asked for it.
|
|
80
|
+
*
|
|
81
|
+
* `state` on its own is replay-window limiting, not the CSRF binding it
|
|
82
|
+
* exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
|
|
83
|
+
* state issued to any visitor validated for any callback, so an attacker
|
|
84
|
+
* could harvest their own state and code, deliver them to a victim over a
|
|
85
|
+
* plain link, and log the victim into the attacker's account. The binding
|
|
86
|
+
* value is the thing the victim's browser carries and the attacker's does
|
|
87
|
+
* not (#36).
|
|
88
|
+
*/
|
|
89
|
+
getAuthorizationUrl(providerName: string): IssuedState;
|
|
90
|
+
/**
|
|
91
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
92
|
+
*
|
|
93
|
+
* Used by the login route when the binding cookie cannot be set: a state the
|
|
94
|
+
* client cannot be bound to is exactly the defect this mechanism exists to
|
|
95
|
+
* prevent, so it must not outlive the request that failed to bind it.
|
|
96
|
+
*/
|
|
97
|
+
discardState(stateToken: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* Validates and consumes a pending state, then completes the flow.
|
|
100
|
+
*
|
|
101
|
+
* `bindingValues` is every value the client presented under the binding
|
|
102
|
+
* cookie's name — see `anyCandidateMatches`.
|
|
103
|
+
*/
|
|
104
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<SessionResult>;
|
|
105
|
+
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
106
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
109
|
+
*
|
|
110
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
111
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
112
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
113
|
+
* heard of it.
|
|
114
|
+
*/
|
|
115
|
+
issueExchangeTicket(session: SessionResult): string;
|
|
116
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
117
|
+
redeemExchangeTicket(ticket: string): RedeemedTicket | null;
|
|
118
|
+
getSession(sessionId: string): unknown;
|
|
119
|
+
logout(sessionId: string): void;
|
|
120
|
+
}
|
|
121
|
+
export {};
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
setup(['authenticate']);
|
|
12
|
+
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
13
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
14
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
15
|
+
export const BINDING_VALUE_BYTES = 32;
|
|
16
|
+
export default class OAuth {
|
|
17
|
+
static instance;
|
|
18
|
+
providers = new Map();
|
|
19
|
+
pendingStates = new Map();
|
|
20
|
+
stateTtl = STATE_TTL_MS;
|
|
21
|
+
sessionManager;
|
|
22
|
+
ticketStore = new TicketStore();
|
|
23
|
+
frontendCallbackUrl;
|
|
24
|
+
constructor() {
|
|
25
|
+
if (OAuth.instance)
|
|
26
|
+
return OAuth.instance;
|
|
27
|
+
OAuth.instance = this;
|
|
28
|
+
}
|
|
29
|
+
async init() {
|
|
30
|
+
// Self-register so log.oauth works even when @stonyx/oauth is in the
|
|
31
|
+
// consumer's `dependencies` (stonyx loader only merges devDependencies).
|
|
32
|
+
const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
|
|
33
|
+
log.defineType(logMethod, logColor);
|
|
34
|
+
const oauthConfig = config.oauth;
|
|
35
|
+
const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
|
|
36
|
+
this.frontendCallbackUrl = frontendCallbackUrl;
|
|
37
|
+
for (const [name, providerConfig] of Object.entries(providers)) {
|
|
38
|
+
const modulePath = providerConfig.module
|
|
39
|
+
? `${config.rootPath}/${providerConfig.module}`
|
|
40
|
+
: `./providers/${name}.js`;
|
|
41
|
+
const { default: Provider } = await import(modulePath);
|
|
42
|
+
const flow = new Provider(providerConfig);
|
|
43
|
+
this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
|
|
44
|
+
}
|
|
45
|
+
this.sessionManager = new SessionManager(sessionDuration);
|
|
46
|
+
await waitForModule('rest-server');
|
|
47
|
+
RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
|
|
48
|
+
log.oauth?.('OAuth module initialized');
|
|
49
|
+
}
|
|
50
|
+
getProvider(name) {
|
|
51
|
+
const provider = this.providers.get(name);
|
|
52
|
+
if (!provider)
|
|
53
|
+
throw new Error(`OAuth provider "${name}" is not configured`);
|
|
54
|
+
return provider;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* SHA-256 of a binding value, hex encoded.
|
|
58
|
+
*
|
|
59
|
+
* The pending record stores the digest so that read access to the map does
|
|
60
|
+
* not hand over the value a callback must present.
|
|
61
|
+
*/
|
|
62
|
+
static hash(value) {
|
|
63
|
+
return createHash('sha256').update(value).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
66
|
+
static digestsMatch(a, b) {
|
|
67
|
+
if (a.length !== b.length)
|
|
68
|
+
return false;
|
|
69
|
+
let difference = 0;
|
|
70
|
+
for (let index = 0; index < a.length; index++) {
|
|
71
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
72
|
+
}
|
|
73
|
+
return difference === 0;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether *any* presented value is the binding value for this record.
|
|
77
|
+
*
|
|
78
|
+
* Every candidate is tried, and the callback is accepted if one matches.
|
|
79
|
+
* Stopping at the first value carrying the cookie's name instead makes a
|
|
80
|
+
* planted cookie a permanent, unauthenticated denial of login: RFC 6265
|
|
81
|
+
* section 5.4 orders the `Cookie` header by path length then creation time,
|
|
82
|
+
* so an attacker with content control on a sibling subdomain sets a
|
|
83
|
+
* same-named cookie once and every subsequent callback for that victim reads
|
|
84
|
+
* theirs, fails the binding check, and burns the state on the way out. The
|
|
85
|
+
* victim cannot recover by retrying.
|
|
86
|
+
*
|
|
87
|
+
* Accepting any match gives an attacker nothing: they would have to present
|
|
88
|
+
* the victim's own binding value, which is the property being checked. And
|
|
89
|
+
* the candidate list is deliberately uncapped — a cap does not bound an
|
|
90
|
+
* attack, it *is* one, reinstating that denial above its own threshold
|
|
91
|
+
* because the planted cookies are the ones that sort first. The work is
|
|
92
|
+
* already bounded by Node's 16 KB header limit.
|
|
93
|
+
*
|
|
94
|
+
* The reduce does not short-circuit, so the work is a function of how many
|
|
95
|
+
* values were presented and not of which one matched.
|
|
96
|
+
*/
|
|
97
|
+
static anyCandidateMatches(candidates, bindingHash) {
|
|
98
|
+
return candidates.reduce((matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched, false);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Starts a flow: an OAuth2 `state` for the provider, and a binding value for
|
|
102
|
+
* the client that asked for it.
|
|
103
|
+
*
|
|
104
|
+
* `state` on its own is replay-window limiting, not the CSRF binding it
|
|
105
|
+
* exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
|
|
106
|
+
* state issued to any visitor validated for any callback, so an attacker
|
|
107
|
+
* could harvest their own state and code, deliver them to a victim over a
|
|
108
|
+
* plain link, and log the victim into the attacker's account. The binding
|
|
109
|
+
* value is the thing the victim's browser carries and the attacker's does
|
|
110
|
+
* not (#36).
|
|
111
|
+
*/
|
|
112
|
+
getAuthorizationUrl(providerName) {
|
|
113
|
+
const { flow } = this.getProvider(providerName);
|
|
114
|
+
const stateToken = randomUUID();
|
|
115
|
+
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
116
|
+
this.pendingStates.set(stateToken, {
|
|
117
|
+
bindingHash: OAuth.hash(bindingValue),
|
|
118
|
+
createdAt: Date.now(),
|
|
119
|
+
});
|
|
120
|
+
return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
124
|
+
*
|
|
125
|
+
* Used by the login route when the binding cookie cannot be set: a state the
|
|
126
|
+
* client cannot be bound to is exactly the defect this mechanism exists to
|
|
127
|
+
* prevent, so it must not outlive the request that failed to bind it.
|
|
128
|
+
*/
|
|
129
|
+
discardState(stateToken) {
|
|
130
|
+
this.pendingStates.delete(stateToken);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Validates and consumes a pending state, then completes the flow.
|
|
134
|
+
*
|
|
135
|
+
* `bindingValues` is every value the client presented under the binding
|
|
136
|
+
* cookie's name — see `anyCandidateMatches`.
|
|
137
|
+
*/
|
|
138
|
+
async handleCallback(providerName, code, stateToken, bindingValues) {
|
|
139
|
+
const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
|
|
140
|
+
if (!record)
|
|
141
|
+
throw new Error('Invalid or missing state token');
|
|
142
|
+
// Consumed on recognition, before the TTL and binding checks, so every
|
|
143
|
+
// state gets exactly one attempt whatever the outcome. Checking the
|
|
144
|
+
// binding first would leave the record in place on a mismatch and turn
|
|
145
|
+
// this endpoint into a repeatable, unauthenticated oracle against the
|
|
146
|
+
// binding value for the state's full lifetime.
|
|
147
|
+
this.pendingStates.delete(stateToken);
|
|
148
|
+
if (Date.now() - record.createdAt > this.stateTtl) {
|
|
149
|
+
throw new Error('State token has expired');
|
|
150
|
+
}
|
|
151
|
+
// No "absent means skip". An empty candidate list is a rejection, which is
|
|
152
|
+
// what makes an attacker-delivered link fail for a victim who never
|
|
153
|
+
// started the flow and therefore holds no binding cookie.
|
|
154
|
+
const candidates = bindingValues.filter(value => value.length > 0);
|
|
155
|
+
if (candidates.length === 0)
|
|
156
|
+
throw new Error('Missing state binding value');
|
|
157
|
+
if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
|
|
158
|
+
throw new Error('State token is not bound to this client');
|
|
159
|
+
}
|
|
160
|
+
// Everything below burns a live authorization code, so the binding is
|
|
161
|
+
// settled before `exchangeCode` is ever reached.
|
|
162
|
+
const { flow, tokenManager } = this.getProvider(providerName);
|
|
163
|
+
const tokens = await tokenManager.getTokens(code);
|
|
164
|
+
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
165
|
+
const user = flow.normalizeUser(rawUser);
|
|
166
|
+
await emit('authenticate', user);
|
|
167
|
+
return this.sessionManager.create(user, tokens);
|
|
168
|
+
}
|
|
169
|
+
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
170
|
+
redirectUriFor(providerName) {
|
|
171
|
+
return this.providers.get(providerName)?.flow.redirectUri;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
175
|
+
*
|
|
176
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
177
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
178
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
179
|
+
* heard of it.
|
|
180
|
+
*/
|
|
181
|
+
issueExchangeTicket(session) {
|
|
182
|
+
return this.ticketStore.issue(session.sessionId, session.expiresAt);
|
|
183
|
+
}
|
|
184
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
185
|
+
redeemExchangeTicket(ticket) {
|
|
186
|
+
return this.ticketStore.redeem(ticket);
|
|
187
|
+
}
|
|
188
|
+
getSession(sessionId) {
|
|
189
|
+
return this.sessionManager.validate(sessionId);
|
|
190
|
+
}
|
|
191
|
+
logout(sessionId) {
|
|
192
|
+
this.sessionManager.destroy(sessionId);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
export interface TokenResult {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken: string | null;
|
|
13
|
+
expiresIn: number;
|
|
14
|
+
}
|
|
15
|
+
export default class OAuthFlow {
|
|
16
|
+
clientId: string;
|
|
17
|
+
clientSecret: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
authorizationUrl: string;
|
|
21
|
+
tokenUrl: string;
|
|
22
|
+
userInfoUrl: string;
|
|
23
|
+
constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }: OAuthConfig);
|
|
24
|
+
buildAuthorizationUrl(stateToken: string): string;
|
|
25
|
+
exchangeCode(code: string): Promise<TokenResult>;
|
|
26
|
+
refreshAccessToken(refreshToken: string): Promise<TokenResult>;
|
|
27
|
+
fetchUserInfo(accessToken: string): Promise<unknown>;
|
|
28
|
+
normalizeUser(rawUser: unknown): unknown;
|
|
29
|
+
revokeToken(_accessToken: string): Promise<void>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export default class OAuthFlow {
|
|
2
|
+
clientId;
|
|
3
|
+
clientSecret;
|
|
4
|
+
redirectUri;
|
|
5
|
+
scopes;
|
|
6
|
+
authorizationUrl;
|
|
7
|
+
tokenUrl;
|
|
8
|
+
userInfoUrl;
|
|
9
|
+
constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }) {
|
|
10
|
+
this.clientId = clientId;
|
|
11
|
+
this.clientSecret = clientSecret;
|
|
12
|
+
this.redirectUri = redirectUri;
|
|
13
|
+
this.scopes = scopes || [];
|
|
14
|
+
this.authorizationUrl = authorizationUrl;
|
|
15
|
+
this.tokenUrl = tokenUrl;
|
|
16
|
+
this.userInfoUrl = userInfoUrl;
|
|
17
|
+
}
|
|
18
|
+
buildAuthorizationUrl(stateToken) {
|
|
19
|
+
const params = new URLSearchParams({
|
|
20
|
+
client_id: this.clientId,
|
|
21
|
+
redirect_uri: this.redirectUri,
|
|
22
|
+
response_type: 'code',
|
|
23
|
+
scope: this.scopes.join(' '),
|
|
24
|
+
state: stateToken,
|
|
25
|
+
});
|
|
26
|
+
return `${this.authorizationUrl}?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
async exchangeCode(code) {
|
|
29
|
+
const response = await fetch(this.tokenUrl, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
client_id: this.clientId,
|
|
34
|
+
client_secret: this.clientSecret,
|
|
35
|
+
grant_type: 'authorization_code',
|
|
36
|
+
code,
|
|
37
|
+
redirect_uri: this.redirectUri,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
throw new Error(`Token exchange failed: ${response.status}`);
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
return {
|
|
44
|
+
accessToken: data.access_token,
|
|
45
|
+
refreshToken: data.refresh_token || null,
|
|
46
|
+
expiresIn: data.expires_in,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async refreshAccessToken(refreshToken) {
|
|
50
|
+
const response = await fetch(this.tokenUrl, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
client_id: this.clientId,
|
|
55
|
+
client_secret: this.clientSecret,
|
|
56
|
+
grant_type: 'refresh_token',
|
|
57
|
+
refresh_token: refreshToken,
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
return {
|
|
64
|
+
accessToken: data.access_token,
|
|
65
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
66
|
+
expiresIn: data.expires_in,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async fetchUserInfo(accessToken) {
|
|
70
|
+
const response = await fetch(this.userInfoUrl, {
|
|
71
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
throw new Error(`User info fetch failed: ${response.status}`);
|
|
75
|
+
return response.json();
|
|
76
|
+
}
|
|
77
|
+
normalizeUser(rawUser) {
|
|
78
|
+
return { raw: rawUser };
|
|
79
|
+
}
|
|
80
|
+
async revokeToken(_accessToken) {
|
|
81
|
+
// Optional — providers override if supported
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import OAuthFlow from '../oauth-flow.js';
|
|
2
|
+
import type { TokenResult } from '../oauth-flow.js';
|
|
3
|
+
interface DiscordProviderConfig {
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
redirectUri: string;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface DiscordUser {
|
|
11
|
+
id: string;
|
|
12
|
+
username: string;
|
|
13
|
+
global_name?: string;
|
|
14
|
+
avatar: string | null;
|
|
15
|
+
email?: string | null;
|
|
16
|
+
}
|
|
17
|
+
interface NormalizedDiscordUser {
|
|
18
|
+
id: string;
|
|
19
|
+
username: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
avatar: string | null;
|
|
22
|
+
email: string | null;
|
|
23
|
+
raw: DiscordUser;
|
|
24
|
+
}
|
|
25
|
+
export default class DiscordProvider extends OAuthFlow {
|
|
26
|
+
constructor(config: DiscordProviderConfig);
|
|
27
|
+
exchangeCode(code: string): Promise<TokenResult>;
|
|
28
|
+
normalizeUser(rawUser: DiscordUser): NormalizedDiscordUser;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import OAuthFlow from '../oauth-flow.js';
|
|
2
|
+
export default class DiscordProvider extends OAuthFlow {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
super({
|
|
5
|
+
...config,
|
|
6
|
+
authorizationUrl: 'https://discord.com/oauth2/authorize',
|
|
7
|
+
tokenUrl: 'https://discord.com/api/oauth2/token',
|
|
8
|
+
userInfoUrl: 'https://discord.com/api/users/@me',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
async exchangeCode(code) {
|
|
12
|
+
const response = await fetch(this.tokenUrl, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
15
|
+
body: new URLSearchParams({
|
|
16
|
+
client_id: this.clientId,
|
|
17
|
+
client_secret: this.clientSecret,
|
|
18
|
+
grant_type: 'authorization_code',
|
|
19
|
+
code,
|
|
20
|
+
redirect_uri: this.redirectUri,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw new Error(`Token exchange failed: ${response.status}`);
|
|
25
|
+
const data = await response.json();
|
|
26
|
+
return {
|
|
27
|
+
accessToken: data.access_token,
|
|
28
|
+
refreshToken: data.refresh_token || null,
|
|
29
|
+
expiresIn: data.expires_in,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
normalizeUser(rawUser) {
|
|
33
|
+
const { id, username, global_name, avatar, email } = rawUser;
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
username,
|
|
37
|
+
displayName: global_name || username,
|
|
38
|
+
avatar: avatar ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png` : null,
|
|
39
|
+
email: email || null,
|
|
40
|
+
raw: rawUser,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface SessionData {
|
|
2
|
+
user: unknown;
|
|
3
|
+
tokens: unknown;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SessionResult {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
user: unknown;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
export default class SessionManager {
|
|
12
|
+
sessions: Map<string, SessionData>;
|
|
13
|
+
duration: number;
|
|
14
|
+
constructor(duration: number);
|
|
15
|
+
create(user: unknown, tokens: unknown): SessionResult;
|
|
16
|
+
get(sessionId: string): SessionData | null;
|
|
17
|
+
destroy(sessionId: string): void;
|
|
18
|
+
validate(sessionId: string): unknown;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export default class SessionManager {
|
|
3
|
+
sessions = new Map();
|
|
4
|
+
duration;
|
|
5
|
+
constructor(duration) {
|
|
6
|
+
this.duration = duration;
|
|
7
|
+
}
|
|
8
|
+
create(user, tokens) {
|
|
9
|
+
const sessionId = randomUUID();
|
|
10
|
+
const expiresAt = Date.now() + (this.duration * 1000);
|
|
11
|
+
this.sessions.set(sessionId, { user, tokens, expiresAt });
|
|
12
|
+
return { sessionId, user, expiresAt };
|
|
13
|
+
}
|
|
14
|
+
get(sessionId) {
|
|
15
|
+
return this.sessions.get(sessionId) || null;
|
|
16
|
+
}
|
|
17
|
+
destroy(sessionId) {
|
|
18
|
+
this.sessions.delete(sessionId);
|
|
19
|
+
}
|
|
20
|
+
validate(sessionId) {
|
|
21
|
+
const session = this.get(sessionId);
|
|
22
|
+
if (!session)
|
|
23
|
+
return null;
|
|
24
|
+
if (Date.now() >= session.expiresAt) {
|
|
25
|
+
this.destroy(sessionId);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return session.user;
|
|
29
|
+
}
|
|
30
|
+
}
|