@stonyx/oauth 0.1.1-beta.5 → 0.1.1-beta.51

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 +4 -0
  2. package/config/environment.js +12 -7
  3. package/config/environment.ts +7 -0
  4. package/dist/auth-request.d.ts +35 -0
  5. package/dist/auth-request.js +68 -0
  6. package/dist/main.d.ts +22 -0
  7. package/dist/main.js +75 -0
  8. package/dist/oauth-flow.d.ts +30 -0
  9. package/dist/oauth-flow.js +83 -0
  10. package/dist/providers/discord.d.ts +30 -0
  11. package/dist/providers/discord.js +43 -0
  12. package/dist/session-manager.d.ts +20 -0
  13. package/dist/session-manager.js +30 -0
  14. package/dist/token-manager.d.ts +15 -0
  15. package/dist/token-manager.js +24 -0
  16. package/package.json +45 -8
  17. package/src/{auth-request.js → auth-request.ts} +26 -6
  18. package/src/{main.js → main.ts} +31 -10
  19. package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
  20. package/src/providers/{discord.js → discord.ts} +29 -3
  21. package/src/{session-manager.js → session-manager.ts} +19 -6
  22. package/src/token-manager.ts +35 -0
  23. package/src/types/node.d.ts +3 -0
  24. package/src/types/stonyx-events.d.ts +4 -0
  25. package/src/types/stonyx-rest-server.d.ts +11 -0
  26. package/src/types/stonyx.d.ts +30 -0
  27. package/.github/workflows/ci.yml +0 -16
  28. package/.github/workflows/publish.yml +0 -51
  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
@@ -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
  },
@@ -1,22 +1,41 @@
1
1
  import config from 'stonyx/config';
2
2
  import log from 'stonyx/log';
3
3
  import { waitForModule } from 'stonyx';
4
+ import { setup, emit } from '@stonyx/events';
4
5
  import RestServer from '@stonyx/rest-server';
5
6
  import TokenManager from './token-manager.js';
6
7
  import SessionManager from './session-manager.js';
7
8
  import AuthRequest from './auth-request.js';
9
+ import type OAuthFlow from './oauth-flow.js';
10
+
11
+ setup(['authenticate']);
12
+
13
+ interface ProviderEntry {
14
+ flow: OAuthFlow;
15
+ tokenManager: TokenManager;
16
+ }
17
+
18
+ interface ProviderConfig {
19
+ module?: string;
20
+ [key: string]: unknown;
21
+ }
8
22
 
9
23
  export default class OAuth {
10
- providers = new Map();
11
- pendingStates = new Map();
24
+ static instance: OAuth | null;
25
+
26
+ providers = new Map<string, ProviderEntry>();
27
+ pendingStates = new Map<string, number>();
28
+ sessionManager!: SessionManager;
29
+ frontendCallbackUrl?: string;
12
30
 
13
31
  constructor() {
14
32
  if (OAuth.instance) return OAuth.instance;
15
33
  OAuth.instance = this;
16
34
  }
17
35
 
18
- async init() {
19
- const { providers, sessionDuration, frontendCallbackUrl } = config.oauth;
36
+ async init(): Promise<void> {
37
+ const oauthConfig = config.oauth;
38
+ const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
20
39
  this.frontendCallbackUrl = frontendCallbackUrl;
21
40
 
22
41
  for (const [name, providerConfig] of Object.entries(providers)) {
@@ -24,7 +43,7 @@ export default class OAuth {
24
43
  ? `${config.rootPath}/${providerConfig.module}`
25
44
  : `./providers/${name}.js`;
26
45
  const { default: Provider } = await import(modulePath);
27
- const flow = new Provider(providerConfig);
46
+ const flow: OAuthFlow = new Provider(providerConfig);
28
47
  this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
29
48
  }
30
49
 
@@ -36,25 +55,26 @@ export default class OAuth {
36
55
  log.oauth?.('OAuth module initialized');
37
56
  }
38
57
 
39
- getProvider(name) {
58
+ getProvider(name: string): ProviderEntry {
40
59
  const provider = this.providers.get(name);
41
60
  if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
42
61
  return provider;
43
62
  }
44
63
 
45
- getAuthorizationUrl(providerName) {
64
+ getAuthorizationUrl(providerName: string): string {
46
65
  const { flow } = this.getProvider(providerName);
47
66
  const stateToken = crypto.randomUUID();
48
67
  this.pendingStates.set(stateToken, Date.now());
49
68
  return flow.buildAuthorizationUrl(stateToken);
50
69
  }
51
70
 
52
- async handleCallback(providerName, code, stateToken) {
71
+ async handleCallback(providerName: string, code: string, stateToken: string) {
53
72
  if (!stateToken || !this.pendingStates.has(stateToken)) {
54
73
  throw new Error('Invalid or missing state token');
55
74
  }
56
75
 
57
76
  const stateCreatedAt = this.pendingStates.get(stateToken);
77
+ if (stateCreatedAt === undefined) throw new Error('State token not found in pending states');
58
78
  this.pendingStates.delete(stateToken);
59
79
 
60
80
  const TEN_MINUTES = 10 * 60 * 1000;
@@ -66,14 +86,15 @@ export default class OAuth {
66
86
  const tokens = await tokenManager.getTokens(code);
67
87
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
68
88
  const user = flow.normalizeUser(rawUser);
89
+ await emit('authenticate', user);
69
90
  return this.sessionManager.create(user, tokens);
70
91
  }
71
92
 
72
- getSession(sessionId) {
93
+ getSession(sessionId: string) {
73
94
  return this.sessionManager.validate(sessionId);
74
95
  }
75
96
 
76
- logout(sessionId) {
97
+ logout(sessionId: string): void {
77
98
  this.sessionManager.destroy(sessionId);
78
99
  }
79
100
  }
@@ -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,3 @@
1
+ declare module 'node:crypto' {
2
+ export function randomUUID(): string;
3
+ }
@@ -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,30 @@
1
+ declare module 'stonyx/config' {
2
+ interface OAuthConfig {
3
+ providers: Record<string, { module?: string; [key: string]: unknown }>;
4
+ sessionDuration: number;
5
+ frontendCallbackUrl?: string;
6
+ }
7
+ interface Config {
8
+ oauth: OAuthConfig;
9
+ rootPath: string;
10
+ [key: string]: unknown;
11
+ }
12
+ const config: Config;
13
+ export default config;
14
+ }
15
+
16
+ declare module 'stonyx/log' {
17
+ const log: Record<string, ((...args: unknown[]) => void) | undefined>;
18
+ export default log;
19
+ }
20
+
21
+ declare module 'stonyx' {
22
+ export function waitForModule(name: string): Promise<void>;
23
+ }
24
+
25
+ declare module 'stonyx/test-helpers' {
26
+ export function setupIntegrationTests(hooks: {
27
+ before(fn: () => void | Promise<void>): void;
28
+ after(fn: () => void | Promise<void>): void;
29
+ }): void;
30
+ }
@@ -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,26 +0,0 @@
1
- export default class TokenManager {
2
- constructor(flow) {
3
- this.flow = flow;
4
- }
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
-
12
- async refresh(refreshToken) {
13
- const tokens = await this.flow.refreshAccessToken(refreshToken);
14
- tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
15
- return tokens;
16
- }
17
-
18
- async revoke(accessToken) {
19
- return this.flow.revokeToken(accessToken);
20
- }
21
-
22
- isExpired(tokenData) {
23
- if (!tokenData?.expiresAt) return true;
24
- return Date.now() >= tokenData.expiresAt;
25
- }
26
- }
@@ -1,18 +0,0 @@
1
- export default {
2
- restServer: {
3
- dir: './test/sample/requests',
4
- },
5
- oauth: {
6
- providers: {
7
- mock: {
8
- clientId: 'test-client-id',
9
- clientSecret: 'test-client-secret',
10
- redirectUri: 'http://localhost:2666/auth/callback/mock',
11
- scopes: ['identify'],
12
- module: './test/sample/providers/mock.js',
13
- }
14
- },
15
- sessionDuration: 3600,
16
- frontendCallbackUrl: 'http://localhost:4200/auth/callback',
17
- }
18
- };