@stonyx/oauth 0.1.1-beta.9 → 0.1.1-beta.90

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 (36) hide show
  1. package/README.md +4 -0
  2. package/dist/auth-request.d.ts +35 -0
  3. package/dist/auth-request.js +68 -0
  4. package/dist/main.d.ts +22 -0
  5. package/dist/main.js +79 -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 -8
  15. package/src/{auth-request.js → auth-request.ts} +26 -6
  16. package/src/{main.js → main.ts} +36 -10
  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 +3 -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 +37 -0
  25. package/.github/workflows/ci.yml +0 -16
  26. package/.github/workflows/publish.yml +0 -51
  27. package/src/token-manager.js +0 -26
  28. package/test/config/environment.js +0 -18
  29. package/test/integration/oauth-test.js +0 -149
  30. package/test/sample/providers/mock.js +0 -40
  31. package/test/sample/requests/.gitkeep +0 -0
  32. package/test/unit/oauth-flow-test.js +0 -137
  33. package/test/unit/providers/discord-test.js +0 -115
  34. package/test/unit/session-manager-test.js +0 -85
  35. package/test/unit/state-validation-test.js +0 -118
  36. package/test/unit/token-manager-test.js +0 -76
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ [![CI](https://github.com/abofs/stonyx-oauth/actions/workflows/ci.yml/badge.svg)](https://github.com/abofs/stonyx-oauth/actions/workflows/ci.yml)
2
+ [![npm version](https://img.shields.io/npm/v/@stonyx/oauth.svg)](https://www.npmjs.com/package/@stonyx/oauth)
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+
1
5
  # @stonyx/oauth
2
6
 
3
7
  OAuth2 authentication module for the Stonyx framework. Provides a generic OAuth2 Authorization Code flow with a provider pattern — ship with Discord support, extensible to any OAuth2 provider.
@@ -0,0 +1,35 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ interface OAuthInstance {
3
+ frontendCallbackUrl?: string;
4
+ getSession(sessionId: string): unknown;
5
+ getAuthorizationUrl(providerName: string): string;
6
+ handleCallback(providerName: string, code: string, stateToken: string): Promise<{
7
+ sessionId: string;
8
+ expiresAt: number;
9
+ }>;
10
+ logout(sessionId: string): void;
11
+ }
12
+ interface RouteRequest {
13
+ headers: Record<string, string | undefined>;
14
+ params: Record<string, string>;
15
+ query: Record<string, string>;
16
+ }
17
+ interface RouteState {
18
+ redirect?: string;
19
+ }
20
+ export default class AuthRequest extends Request {
21
+ oauth: OAuthInstance;
22
+ constructor(oauth: OAuthInstance);
23
+ handlers: {
24
+ get: {
25
+ '/': ({ headers }: RouteRequest) => {};
26
+ '/login/:provider': (req: RouteRequest, state: RouteState) => 404 | undefined;
27
+ '/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
28
+ sessionId: string;
29
+ expiresAt: number;
30
+ } | 400 | 500 | undefined>;
31
+ '/logout': ({ headers }: RouteRequest) => void;
32
+ };
33
+ };
34
+ }
35
+ export {};
@@ -0,0 +1,68 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ export default class AuthRequest extends Request {
3
+ oauth;
4
+ constructor(oauth) {
5
+ super();
6
+ this.oauth = oauth;
7
+ }
8
+ handlers = {
9
+ get: {
10
+ '/': ({ headers }) => {
11
+ const sessionId = headers['session-id'];
12
+ if (!sessionId)
13
+ return 401;
14
+ const user = this.oauth.getSession(sessionId);
15
+ if (!user)
16
+ return 401;
17
+ return user;
18
+ },
19
+ '/login/:provider': (req, state) => {
20
+ const { provider: providerName } = req.params;
21
+ try {
22
+ const url = this.oauth.getAuthorizationUrl(providerName);
23
+ state.redirect = url;
24
+ }
25
+ catch {
26
+ return 404;
27
+ }
28
+ },
29
+ '/callback/:provider': async (req, state) => {
30
+ const { provider: providerName } = req.params;
31
+ const { code, state: stateToken, error } = req.query;
32
+ if (error) {
33
+ if (this.oauth.frontendCallbackUrl) {
34
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
35
+ return;
36
+ }
37
+ return 400;
38
+ }
39
+ if (!code)
40
+ return 400;
41
+ try {
42
+ const session = await this.oauth.handleCallback(providerName, code, stateToken);
43
+ if (this.oauth.frontendCallbackUrl) {
44
+ const params = new URLSearchParams({
45
+ sessionId: session.sessionId,
46
+ expiresAt: String(session.expiresAt),
47
+ });
48
+ state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
49
+ return;
50
+ }
51
+ return session;
52
+ }
53
+ catch {
54
+ if (this.oauth.frontendCallbackUrl) {
55
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
56
+ return;
57
+ }
58
+ return 500;
59
+ }
60
+ },
61
+ '/logout': ({ headers }) => {
62
+ const sessionId = headers['session-id'];
63
+ if (sessionId)
64
+ this.oauth.logout(sessionId);
65
+ },
66
+ }
67
+ };
68
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import TokenManager from './token-manager.js';
2
+ import SessionManager from './session-manager.js';
3
+ import type OAuthFlow from './oauth-flow.js';
4
+ interface ProviderEntry {
5
+ flow: OAuthFlow;
6
+ tokenManager: TokenManager;
7
+ }
8
+ export default class OAuth {
9
+ static instance: OAuth | null;
10
+ providers: Map<string, ProviderEntry>;
11
+ pendingStates: Map<string, number>;
12
+ sessionManager: SessionManager;
13
+ frontendCallbackUrl?: string;
14
+ constructor();
15
+ init(): Promise<void>;
16
+ getProvider(name: string): ProviderEntry;
17
+ getAuthorizationUrl(providerName: string): string;
18
+ handleCallback(providerName: string, code: string, stateToken: string): Promise<import("./session-manager.js").SessionResult>;
19
+ getSession(sessionId: string): unknown;
20
+ logout(sessionId: string): void;
21
+ }
22
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,79 @@
1
+ import config from 'stonyx/config';
2
+ import log from 'stonyx/log';
3
+ import { waitForModule } from 'stonyx';
4
+ import { setup, emit } from '@stonyx/events';
5
+ import RestServer from '@stonyx/rest-server';
6
+ import TokenManager from './token-manager.js';
7
+ import SessionManager from './session-manager.js';
8
+ import AuthRequest from './auth-request.js';
9
+ setup(['authenticate']);
10
+ export default class OAuth {
11
+ static instance;
12
+ providers = new Map();
13
+ pendingStates = new Map();
14
+ sessionManager;
15
+ frontendCallbackUrl;
16
+ constructor() {
17
+ if (OAuth.instance)
18
+ return OAuth.instance;
19
+ OAuth.instance = this;
20
+ }
21
+ async init() {
22
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
23
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
24
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
25
+ log.defineType(logMethod, logColor);
26
+ const oauthConfig = config.oauth;
27
+ const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
28
+ this.frontendCallbackUrl = frontendCallbackUrl;
29
+ for (const [name, providerConfig] of Object.entries(providers)) {
30
+ const modulePath = providerConfig.module
31
+ ? `${config.rootPath}/${providerConfig.module}`
32
+ : `./providers/${name}.js`;
33
+ const { default: Provider } = await import(modulePath);
34
+ const flow = new Provider(providerConfig);
35
+ this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
36
+ }
37
+ this.sessionManager = new SessionManager(sessionDuration);
38
+ await waitForModule('rest-server');
39
+ RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
40
+ log.oauth?.('OAuth module initialized');
41
+ }
42
+ getProvider(name) {
43
+ const provider = this.providers.get(name);
44
+ if (!provider)
45
+ throw new Error(`OAuth provider "${name}" is not configured`);
46
+ return provider;
47
+ }
48
+ getAuthorizationUrl(providerName) {
49
+ const { flow } = this.getProvider(providerName);
50
+ const stateToken = crypto.randomUUID();
51
+ this.pendingStates.set(stateToken, Date.now());
52
+ return flow.buildAuthorizationUrl(stateToken);
53
+ }
54
+ async handleCallback(providerName, code, stateToken) {
55
+ if (!stateToken || !this.pendingStates.has(stateToken)) {
56
+ throw new Error('Invalid or missing state token');
57
+ }
58
+ const stateCreatedAt = this.pendingStates.get(stateToken);
59
+ if (stateCreatedAt === undefined)
60
+ throw new Error('State token not found in pending states');
61
+ this.pendingStates.delete(stateToken);
62
+ const TEN_MINUTES = 10 * 60 * 1000;
63
+ if (Date.now() - stateCreatedAt > TEN_MINUTES) {
64
+ throw new Error('State token has expired');
65
+ }
66
+ const { flow, tokenManager } = this.getProvider(providerName);
67
+ const tokens = await tokenManager.getTokens(code);
68
+ const rawUser = await flow.fetchUserInfo(tokens.accessToken);
69
+ const user = flow.normalizeUser(rawUser);
70
+ await emit('authenticate', user);
71
+ return this.sessionManager.create(user, tokens);
72
+ }
73
+ getSession(sessionId) {
74
+ return this.sessionManager.validate(sessionId);
75
+ }
76
+ logout(sessionId) {
77
+ this.sessionManager.destroy(sessionId);
78
+ }
79
+ }
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import type OAuthFlow from './oauth-flow.js';
2
+ import type { TokenResult } from './oauth-flow.js';
3
+ export interface TokenData extends TokenResult {
4
+ expiresAt: number;
5
+ }
6
+ export default class TokenManager {
7
+ flow: OAuthFlow;
8
+ constructor(flow: OAuthFlow);
9
+ getTokens(code: string): Promise<TokenData>;
10
+ refresh(refreshToken: string): Promise<TokenData>;
11
+ revoke(accessToken: string): Promise<void>;
12
+ isExpired(tokenData: {
13
+ expiresAt?: number;
14
+ } | null | undefined): boolean;
15
+ }
@@ -0,0 +1,24 @@
1
+ export default class TokenManager {
2
+ flow;
3
+ constructor(flow) {
4
+ this.flow = flow;
5
+ }
6
+ async getTokens(code) {
7
+ const tokens = await this.flow.exchangeCode(code);
8
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
9
+ return tokens;
10
+ }
11
+ async refresh(refreshToken) {
12
+ const tokens = await this.flow.refreshAccessToken(refreshToken);
13
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
14
+ return tokens;
15
+ }
16
+ async revoke(accessToken) {
17
+ return this.flow.revokeToken(accessToken);
18
+ }
19
+ isExpired(tokenData) {
20
+ if (!tokenData?.expiresAt)
21
+ return true;
22
+ return Date.now() >= tokenData.expiresAt;
23
+ }
24
+ }
package/package.json CHANGED
@@ -4,39 +4,76 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-beta.9",
7
+ "version": "0.1.1-beta.90",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/abofs/stonyx-oauth.git"
12
12
  },
13
- "main": "src/main.js",
13
+ "main": "dist/main.js",
14
14
  "type": "module",
15
15
  "exports": {
16
- ".": "./src/main.js"
16
+ ".": {
17
+ "types": "./dist/main.d.ts",
18
+ "default": "./dist/main.js"
19
+ },
20
+ "./oauth-flow": {
21
+ "types": "./dist/oauth-flow.d.ts",
22
+ "default": "./dist/oauth-flow.js"
23
+ },
24
+ "./auth-request": {
25
+ "types": "./dist/auth-request.d.ts",
26
+ "default": "./dist/auth-request.js"
27
+ },
28
+ "./session-manager": {
29
+ "types": "./dist/session-manager.d.ts",
30
+ "default": "./dist/session-manager.js"
31
+ },
32
+ "./token-manager": {
33
+ "types": "./dist/token-manager.d.ts",
34
+ "default": "./dist/token-manager.js"
35
+ },
36
+ "./providers/discord": {
37
+ "types": "./dist/providers/discord.d.ts",
38
+ "default": "./dist/providers/discord.js"
39
+ }
17
40
  },
18
41
  "author": "Stone Costa",
19
42
  "license": "Apache-2.0",
20
43
  "contributors": [
21
44
  "Stone Costa <stone.costa@synamicd.com>"
22
45
  ],
46
+ "files": [
47
+ "dist",
48
+ "src",
49
+ "config",
50
+ "README.md"
51
+ ],
23
52
  "publishConfig": {
24
53
  "access": "public",
25
54
  "provenance": true
26
55
  },
27
56
  "dependencies": {
28
- "stonyx": "0.2.3-beta.6"
57
+ "@stonyx/events": "0.1.1-beta.48",
58
+ "stonyx": "0.2.3-beta.64"
29
59
  },
30
60
  "peerDependencies": {
31
61
  "@stonyx/rest-server": ">=0.2.1-beta.11"
32
62
  },
33
63
  "devDependencies": {
34
- "@stonyx/rest-server": "0.2.1-beta.16",
35
- "@stonyx/utils": "0.2.3-beta.5",
64
+ "@stonyx/rest-server": "0.2.1-beta.62",
65
+ "@stonyx/utils": "0.2.3-beta.23",
66
+ "@stonyx/logs": "1.0.1-beta.16",
67
+ "@types/qunit": "^2.19.13",
68
+ "@types/sinon": "^21.0.1",
36
69
  "qunit": "^2.24.1",
37
- "sinon": "^21.0.0"
70
+ "sinon": "^21.0.0",
71
+ "tsx": "^4.21.0",
72
+ "typescript": "^5.8.3"
38
73
  },
39
74
  "scripts": {
40
- "test": "stonyx test"
75
+ "build": "tsc",
76
+ "build:test": "tsc -p tsconfig.test.json",
77
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
41
78
  }
42
79
  }
@@ -1,14 +1,34 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
 
3
+ interface OAuthInstance {
4
+ frontendCallbackUrl?: string;
5
+ getSession(sessionId: string): unknown;
6
+ getAuthorizationUrl(providerName: string): string;
7
+ handleCallback(providerName: string, code: string, stateToken: string): Promise<{ sessionId: string; expiresAt: number }>;
8
+ logout(sessionId: string): void;
9
+ }
10
+
11
+ interface RouteRequest {
12
+ headers: Record<string, string | undefined>;
13
+ params: Record<string, string>;
14
+ query: Record<string, string>;
15
+ }
16
+
17
+ interface RouteState {
18
+ redirect?: string;
19
+ }
20
+
3
21
  export default class AuthRequest extends Request {
4
- constructor(oauth) {
22
+ oauth: OAuthInstance;
23
+
24
+ constructor(oauth: OAuthInstance) {
5
25
  super();
6
26
  this.oauth = oauth;
7
27
  }
8
28
 
9
29
  handlers = {
10
30
  get: {
11
- '/': ({ headers }) => {
31
+ '/': ({ headers }: RouteRequest) => {
12
32
  const sessionId = headers['session-id'];
13
33
  if (!sessionId) return 401;
14
34
 
@@ -18,7 +38,7 @@ export default class AuthRequest extends Request {
18
38
  return user;
19
39
  },
20
40
 
21
- '/login/:provider': (req, state) => {
41
+ '/login/:provider': (req: RouteRequest, state: RouteState) => {
22
42
  const { provider: providerName } = req.params;
23
43
 
24
44
  try {
@@ -29,7 +49,7 @@ export default class AuthRequest extends Request {
29
49
  }
30
50
  },
31
51
 
32
- '/callback/:provider': async (req, state) => {
52
+ '/callback/:provider': async (req: RouteRequest, state: RouteState) => {
33
53
  const { provider: providerName } = req.params;
34
54
  const { code, state: stateToken, error } = req.query;
35
55
 
@@ -49,7 +69,7 @@ export default class AuthRequest extends Request {
49
69
  if (this.oauth.frontendCallbackUrl) {
50
70
  const params = new URLSearchParams({
51
71
  sessionId: session.sessionId,
52
- expiresAt: session.expiresAt,
72
+ expiresAt: String(session.expiresAt),
53
73
  });
54
74
  state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
55
75
  return;
@@ -65,7 +85,7 @@ export default class AuthRequest extends Request {
65
85
  }
66
86
  },
67
87
 
68
- '/logout': ({ headers }) => {
88
+ '/logout': ({ headers }: RouteRequest) => {
69
89
  const sessionId = headers['session-id'];
70
90
  if (sessionId) this.oauth.logout(sessionId);
71
91
  },