@stonyx/oauth 0.1.1-beta.17 → 0.1.1-beta.171

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.
Files changed (38) hide show
  1. package/README.md +72 -0
  2. package/dist/auth-request.d.ts +97 -0
  3. package/dist/auth-request.js +187 -0
  4. package/dist/main.d.ts +106 -0
  5. package/dist/main.js +177 -0
  6. package/dist/oauth-flow.d.ts +30 -0
  7. package/dist/oauth-flow.js +83 -0
  8. package/dist/providers/discord.d.ts +30 -0
  9. package/dist/providers/discord.js +43 -0
  10. package/dist/session-manager.d.ts +20 -0
  11. package/dist/session-manager.js +30 -0
  12. package/dist/token-manager.d.ts +15 -0
  13. package/dist/token-manager.js +24 -0
  14. package/package.json +45 -9
  15. package/src/auth-request.ts +271 -0
  16. package/src/main.ts +238 -0
  17. package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
  18. package/src/providers/{discord.js → discord.ts} +29 -3
  19. package/src/{session-manager.js → session-manager.ts} +19 -6
  20. package/src/token-manager.ts +35 -0
  21. package/src/types/node.d.ts +19 -0
  22. package/src/types/stonyx-events.d.ts +4 -0
  23. package/src/types/stonyx-rest-server.d.ts +11 -0
  24. package/src/types/stonyx.d.ts +38 -0
  25. package/.github/workflows/ci.yml +0 -16
  26. package/.github/workflows/publish.yml +0 -51
  27. package/src/auth-request.js +0 -74
  28. package/src/main.js +0 -83
  29. package/src/token-manager.js +0 -26
  30. package/test/config/environment.js +0 -18
  31. package/test/integration/oauth-test.js +0 -149
  32. package/test/sample/providers/mock.js +0 -40
  33. package/test/sample/requests/.gitkeep +0 -0
  34. package/test/unit/oauth-flow-test.js +0 -137
  35. package/test/unit/providers/discord-test.js +0 -115
  36. package/test/unit/session-manager-test.js +0 -85
  37. package/test/unit/state-validation-test.js +0 -118
  38. package/test/unit/token-manager-test.js +0 -76
package/src/main.ts ADDED
@@ -0,0 +1,238 @@
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 AuthRequest from './auth-request.js';
10
+ import type OAuthFlow from './oauth-flow.js';
11
+
12
+ setup(['authenticate']);
13
+
14
+ /** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
15
+ export const STATE_TTL_MS = 10 * 60 * 1000;
16
+
17
+ /** Entropy of the client-held binding value, in bytes. */
18
+ export const BINDING_VALUE_BYTES = 32;
19
+
20
+ interface ProviderEntry {
21
+ flow: OAuthFlow;
22
+ tokenManager: TokenManager;
23
+ }
24
+
25
+ /**
26
+ * A flow that is in progress.
27
+ *
28
+ * Holds a *digest* of the binding value rather than the value itself: a
29
+ * callback is only accepted when the caller presents the plaintext that hashes
30
+ * to `bindingHash`, so the record on its own unlocks nothing.
31
+ */
32
+ export interface PendingState {
33
+ bindingHash: string;
34
+ createdAt: number;
35
+ }
36
+
37
+ export interface IssuedState {
38
+ /** Sent to the provider as the OAuth2 `state` parameter. */
39
+ url: string;
40
+ /** Retained so a login that cannot be bound can withdraw its own state. */
41
+ stateToken: string;
42
+ /** Held by the client that started the flow, never by the provider. */
43
+ bindingValue: string;
44
+ }
45
+
46
+ interface ProviderConfig {
47
+ module?: string;
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ export default class OAuth {
52
+ static instance: OAuth | null;
53
+
54
+ providers = new Map<string, ProviderEntry>();
55
+ pendingStates = new Map<string, PendingState>();
56
+ stateTtl = STATE_TTL_MS;
57
+ sessionManager!: SessionManager;
58
+ frontendCallbackUrl?: string;
59
+
60
+ constructor() {
61
+ if (OAuth.instance) return OAuth.instance;
62
+ OAuth.instance = this;
63
+ }
64
+
65
+ async init(): Promise<void> {
66
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
67
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
68
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
69
+ log.defineType(logMethod, logColor);
70
+
71
+ const oauthConfig = config.oauth;
72
+ const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
73
+ this.frontendCallbackUrl = frontendCallbackUrl;
74
+
75
+ for (const [name, providerConfig] of Object.entries(providers)) {
76
+ const modulePath = providerConfig.module
77
+ ? `${config.rootPath}/${providerConfig.module}`
78
+ : `./providers/${name}.js`;
79
+ const { default: Provider } = await import(modulePath);
80
+ const flow: OAuthFlow = new Provider(providerConfig);
81
+ this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
82
+ }
83
+
84
+ this.sessionManager = new SessionManager(sessionDuration);
85
+
86
+ await waitForModule('rest-server');
87
+ RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
88
+
89
+ log.oauth?.('OAuth module initialized');
90
+ }
91
+
92
+ getProvider(name: string): ProviderEntry {
93
+ const provider = this.providers.get(name);
94
+ if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
95
+ return provider;
96
+ }
97
+
98
+ /**
99
+ * SHA-256 of a binding value, hex encoded.
100
+ *
101
+ * The pending record stores the digest so that read access to the map does
102
+ * not hand over the value a callback must present.
103
+ */
104
+ static hash(value: string): string {
105
+ return createHash('sha256').update(value).digest('hex');
106
+ }
107
+
108
+ /** Length-independent, content-constant-time comparison of two digests. */
109
+ static digestsMatch(a: string, b: string): boolean {
110
+ if (a.length !== b.length) return false;
111
+
112
+ let difference = 0;
113
+ for (let index = 0; index < a.length; index++) {
114
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
115
+ }
116
+
117
+ return difference === 0;
118
+ }
119
+
120
+ /**
121
+ * Whether *any* presented value is the binding value for this record.
122
+ *
123
+ * Every candidate is tried, and the callback is accepted if one matches.
124
+ * Stopping at the first value carrying the cookie's name instead makes a
125
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
126
+ * section 5.4 orders the `Cookie` header by path length then creation time,
127
+ * so an attacker with content control on a sibling subdomain sets a
128
+ * same-named cookie once and every subsequent callback for that victim reads
129
+ * theirs, fails the binding check, and burns the state on the way out. The
130
+ * victim cannot recover by retrying.
131
+ *
132
+ * Accepting any match gives an attacker nothing: they would have to present
133
+ * the victim's own binding value, which is the property being checked. And
134
+ * the candidate list is deliberately uncapped — a cap does not bound an
135
+ * attack, it *is* one, reinstating that denial above its own threshold
136
+ * because the planted cookies are the ones that sort first. The work is
137
+ * already bounded by Node's 16 KB header limit.
138
+ *
139
+ * The reduce does not short-circuit, so the work is a function of how many
140
+ * values were presented and not of which one matched.
141
+ */
142
+ static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean {
143
+ return candidates.reduce(
144
+ (matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched,
145
+ false,
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Starts a flow: an OAuth2 `state` for the provider, and a binding value for
151
+ * the client that asked for it.
152
+ *
153
+ * `state` on its own is replay-window limiting, not the CSRF binding it
154
+ * exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
155
+ * state issued to any visitor validated for any callback, so an attacker
156
+ * could harvest their own state and code, deliver them to a victim over a
157
+ * plain link, and log the victim into the attacker's account. The binding
158
+ * value is the thing the victim's browser carries and the attacker's does
159
+ * not (#36).
160
+ */
161
+ getAuthorizationUrl(providerName: string): IssuedState {
162
+ const { flow } = this.getProvider(providerName);
163
+ const stateToken = randomUUID();
164
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
165
+
166
+ this.pendingStates.set(stateToken, {
167
+ bindingHash: OAuth.hash(bindingValue),
168
+ createdAt: Date.now(),
169
+ });
170
+
171
+ return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
172
+ }
173
+
174
+ /**
175
+ * Withdraws a state that was issued but could not be handed to a client.
176
+ *
177
+ * Used by the login route when the binding cookie cannot be set: a state the
178
+ * client cannot be bound to is exactly the defect this mechanism exists to
179
+ * prevent, so it must not outlive the request that failed to bind it.
180
+ */
181
+ discardState(stateToken: string): void {
182
+ this.pendingStates.delete(stateToken);
183
+ }
184
+
185
+ /**
186
+ * Validates and consumes a pending state, then completes the flow.
187
+ *
188
+ * `bindingValues` is every value the client presented under the binding
189
+ * cookie's name — see `anyCandidateMatches`.
190
+ */
191
+ async handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]) {
192
+ const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
193
+ if (!record) throw new Error('Invalid or missing state token');
194
+
195
+ // Consumed on recognition, before the TTL and binding checks, so every
196
+ // state gets exactly one attempt whatever the outcome. Checking the
197
+ // binding first would leave the record in place on a mismatch and turn
198
+ // this endpoint into a repeatable, unauthenticated oracle against the
199
+ // binding value for the state's full lifetime.
200
+ this.pendingStates.delete(stateToken);
201
+
202
+ if (Date.now() - record.createdAt > this.stateTtl) {
203
+ throw new Error('State token has expired');
204
+ }
205
+
206
+ // No "absent means skip". An empty candidate list is a rejection, which is
207
+ // what makes an attacker-delivered link fail for a victim who never
208
+ // started the flow and therefore holds no binding cookie.
209
+ const candidates = bindingValues.filter(value => value.length > 0);
210
+ if (candidates.length === 0) throw new Error('Missing state binding value');
211
+
212
+ if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
213
+ throw new Error('State token is not bound to this client');
214
+ }
215
+
216
+ // Everything below burns a live authorization code, so the binding is
217
+ // settled before `exchangeCode` is ever reached.
218
+ const { flow, tokenManager } = this.getProvider(providerName);
219
+ const tokens = await tokenManager.getTokens(code);
220
+ const rawUser = await flow.fetchUserInfo(tokens.accessToken);
221
+ const user = flow.normalizeUser(rawUser);
222
+ await emit('authenticate', user);
223
+ return this.sessionManager.create(user, tokens);
224
+ }
225
+
226
+ /** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
227
+ redirectUriFor(providerName: string): string | undefined {
228
+ return this.providers.get(providerName)?.flow.redirectUri;
229
+ }
230
+
231
+ getSession(sessionId: string) {
232
+ return this.sessionManager.validate(sessionId);
233
+ }
234
+
235
+ logout(sessionId: string): void {
236
+ this.sessionManager.destroy(sessionId);
237
+ }
238
+ }
@@ -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,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
+ }
@@ -1,16 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- branches: [dev, main]
6
-
7
- concurrency:
8
- group: ci-${{ github.head_ref || github.ref }}
9
- cancel-in-progress: true
10
-
11
- permissions:
12
- contents: read
13
-
14
- jobs:
15
- test:
16
- uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
@@ -1,51 +0,0 @@
1
- name: Publish to NPM
2
-
3
- on:
4
- repository_dispatch:
5
- types: [cascade-publish]
6
- workflow_dispatch:
7
- inputs:
8
- version-type:
9
- description: 'Version type'
10
- required: true
11
- type: choice
12
- options:
13
- - patch
14
- - minor
15
- - major
16
- custom-version:
17
- description: 'Custom version (optional, overrides version-type)'
18
- required: false
19
- type: string
20
- pull_request:
21
- types: [opened, synchronize, reopened]
22
- branches: [main]
23
- push:
24
- branches: [main]
25
-
26
- concurrency:
27
- group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
28
- cancel-in-progress: false
29
-
30
- permissions:
31
- contents: write
32
- id-token: write
33
- pull-requests: write
34
-
35
- jobs:
36
- publish:
37
- if: "!contains(github.event.head_commit.message, '[skip ci]')"
38
- uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
39
- with:
40
- version-type: ${{ github.event.inputs.version-type }}
41
- custom-version: ${{ github.event.inputs.custom-version }}
42
- cascade-source: ${{ github.event.client_payload.source_package || '' }}
43
- secrets: inherit
44
-
45
- cascade:
46
- needs: publish
47
- uses: abofs/stonyx-workflows/.github/workflows/cascade.yml@main
48
- with:
49
- package-name: ${{ needs.publish.outputs.package-name }}
50
- published-version: ${{ needs.publish.outputs.published-version }}
51
- secrets: inherit
@@ -1,74 +0,0 @@
1
- import { Request } from '@stonyx/rest-server';
2
-
3
- export default class AuthRequest extends Request {
4
- constructor(oauth) {
5
- super();
6
- this.oauth = oauth;
7
- }
8
-
9
- handlers = {
10
- get: {
11
- '/': ({ headers }) => {
12
- const sessionId = headers['session-id'];
13
- if (!sessionId) return 401;
14
-
15
- const user = this.oauth.getSession(sessionId);
16
- if (!user) return 401;
17
-
18
- return user;
19
- },
20
-
21
- '/login/:provider': (req, state) => {
22
- const { provider: providerName } = req.params;
23
-
24
- try {
25
- const url = this.oauth.getAuthorizationUrl(providerName);
26
- state.redirect = url;
27
- } catch {
28
- return 404;
29
- }
30
- },
31
-
32
- '/callback/:provider': async (req, state) => {
33
- const { provider: providerName } = req.params;
34
- const { code, state: stateToken, error } = req.query;
35
-
36
- if (error) {
37
- if (this.oauth.frontendCallbackUrl) {
38
- state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
39
- return;
40
- }
41
- return 400;
42
- }
43
-
44
- if (!code) return 400;
45
-
46
- try {
47
- const session = await this.oauth.handleCallback(providerName, code, stateToken);
48
-
49
- if (this.oauth.frontendCallbackUrl) {
50
- const params = new URLSearchParams({
51
- sessionId: session.sessionId,
52
- expiresAt: session.expiresAt,
53
- });
54
- state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
55
- return;
56
- }
57
-
58
- return session;
59
- } catch {
60
- if (this.oauth.frontendCallbackUrl) {
61
- state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
62
- return;
63
- }
64
- return 500;
65
- }
66
- },
67
-
68
- '/logout': ({ headers }) => {
69
- const sessionId = headers['session-id'];
70
- if (sessionId) this.oauth.logout(sessionId);
71
- },
72
- }
73
- };
74
- }