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

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
@@ -1,22 +1,46 @@
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
+ // Self-register so log.oauth works even when @stonyx/oauth is in the
38
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
39
+ const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
40
+ log.defineType(logMethod, logColor);
41
+
42
+ const oauthConfig = config.oauth;
43
+ const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
20
44
  this.frontendCallbackUrl = frontendCallbackUrl;
21
45
 
22
46
  for (const [name, providerConfig] of Object.entries(providers)) {
@@ -24,7 +48,7 @@ export default class OAuth {
24
48
  ? `${config.rootPath}/${providerConfig.module}`
25
49
  : `./providers/${name}.js`;
26
50
  const { default: Provider } = await import(modulePath);
27
- const flow = new Provider(providerConfig);
51
+ const flow: OAuthFlow = new Provider(providerConfig);
28
52
  this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
29
53
  }
30
54
 
@@ -36,25 +60,26 @@ export default class OAuth {
36
60
  log.oauth?.('OAuth module initialized');
37
61
  }
38
62
 
39
- getProvider(name) {
63
+ getProvider(name: string): ProviderEntry {
40
64
  const provider = this.providers.get(name);
41
65
  if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
42
66
  return provider;
43
67
  }
44
68
 
45
- getAuthorizationUrl(providerName) {
69
+ getAuthorizationUrl(providerName: string): string {
46
70
  const { flow } = this.getProvider(providerName);
47
71
  const stateToken = crypto.randomUUID();
48
72
  this.pendingStates.set(stateToken, Date.now());
49
73
  return flow.buildAuthorizationUrl(stateToken);
50
74
  }
51
75
 
52
- async handleCallback(providerName, code, stateToken) {
76
+ async handleCallback(providerName: string, code: string, stateToken: string) {
53
77
  if (!stateToken || !this.pendingStates.has(stateToken)) {
54
78
  throw new Error('Invalid or missing state token');
55
79
  }
56
80
 
57
81
  const stateCreatedAt = this.pendingStates.get(stateToken);
82
+ if (stateCreatedAt === undefined) throw new Error('State token not found in pending states');
58
83
  this.pendingStates.delete(stateToken);
59
84
 
60
85
  const TEN_MINUTES = 10 * 60 * 1000;
@@ -66,14 +91,15 @@ export default class OAuth {
66
91
  const tokens = await tokenManager.getTokens(code);
67
92
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
68
93
  const user = flow.normalizeUser(rawUser);
94
+ await emit('authenticate', user);
69
95
  return this.sessionManager.create(user, tokens);
70
96
  }
71
97
 
72
- getSession(sessionId) {
98
+ getSession(sessionId: string) {
73
99
  return this.sessionManager.validate(sessionId);
74
100
  }
75
101
 
76
- logout(sessionId) {
102
+ logout(sessionId: string): void {
77
103
  this.sessionManager.destroy(sessionId);
78
104
  }
79
105
  }
@@ -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,37 @@
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
+ defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
22
+ [key: string]: unknown;
23
+ }
24
+ const log: Log;
25
+ export default log;
26
+ }
27
+
28
+ declare module 'stonyx' {
29
+ export function waitForModule(name: string): Promise<void>;
30
+ }
31
+
32
+ declare module 'stonyx/test-helpers' {
33
+ export function setupIntegrationTests(hooks: {
34
+ before(fn: () => void | Promise<void>): void;
35
+ after(fn: () => void | Promise<void>): void;
36
+ }): void;
37
+ }
@@ -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
- };
@@ -1,149 +0,0 @@
1
- import QUnit from 'qunit';
2
- import RestServer from '@stonyx/rest-server';
3
- import config from 'stonyx/config';
4
- import { setupIntegrationTests } from 'stonyx/test-helpers';
5
- import OAuth from '../../src/main.js';
6
-
7
- const { module, test } = QUnit;
8
- let endpoint;
9
-
10
- async function getValidState(endpoint) {
11
- const loginResponse = await fetch(`${endpoint}/auth/login/mock`, { redirect: 'manual' });
12
- const location = loginResponse.headers.get('location');
13
- const url = new URL(location);
14
- return url.searchParams.get('state');
15
- }
16
-
17
- module('[Integration] OAuth', function(hooks) {
18
- setupIntegrationTests(hooks);
19
-
20
- hooks.before(function() {
21
- endpoint = `http://localhost:${config.restServer.port}`;
22
- });
23
-
24
- hooks.after(function() {
25
- RestServer.close();
26
- });
27
-
28
- test('GET /auth/login/mock redirects to provider auth URL', async function(assert) {
29
- const response = await fetch(`${endpoint}/auth/login/mock`, { redirect: 'manual' });
30
-
31
- assert.equal(response.status, 302);
32
-
33
- const location = response.headers.get('location');
34
- assert.ok(location.startsWith('https://mock.provider/oauth/authorize?'));
35
- assert.ok(location.includes('client_id=test-client-id'));
36
- assert.ok(location.includes('response_type=code'));
37
- });
38
-
39
- test('GET /auth/login/nonexistent returns 404', async function(assert) {
40
- const response = await fetch(`${endpoint}/auth/login/nonexistent`, { redirect: 'manual' });
41
-
42
- assert.equal(response.status, 404);
43
- });
44
-
45
- test('GET /auth/callback/mock with valid state redirects to frontend with session', async function(assert) {
46
- const stateToken = await getValidState(endpoint);
47
- const response = await fetch(`${endpoint}/auth/callback/mock?code=test-auth-code&state=${stateToken}`, { redirect: 'manual' });
48
-
49
- assert.equal(response.status, 302);
50
-
51
- const location = response.headers.get('location');
52
- const redirectUrl = new URL(location);
53
- assert.equal(redirectUrl.origin + redirectUrl.pathname, 'http://localhost:4200/auth/callback');
54
- assert.ok(redirectUrl.searchParams.get('sessionId'), 'redirect includes sessionId');
55
- assert.ok(redirectUrl.searchParams.get('expiresAt'), 'redirect includes expiresAt');
56
- });
57
-
58
- test('GET /auth with valid session returns user', async function(assert) {
59
- const stateToken = await getValidState(endpoint);
60
- const callbackResponse = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state=${stateToken}`, { redirect: 'manual' });
61
- const location = callbackResponse.headers.get('location');
62
- const sessionId = new URL(location).searchParams.get('sessionId');
63
-
64
- const response = await fetch(`${endpoint}/auth`, {
65
- headers: { 'session-id': sessionId },
66
- });
67
- const data = await response.json();
68
-
69
- assert.equal(response.status, 200);
70
- assert.equal(data.id, 'mock-user-123');
71
- });
72
-
73
- test('GET /auth without session returns 401', async function(assert) {
74
- const response = await fetch(`${endpoint}/auth`);
75
-
76
- assert.equal(response.status, 401);
77
- });
78
-
79
- test('GET /auth with invalid session returns 401', async function(assert) {
80
- const response = await fetch(`${endpoint}/auth`, {
81
- headers: { 'session-id': 'invalid-session' },
82
- });
83
-
84
- assert.equal(response.status, 401);
85
- });
86
-
87
- test('GET /auth/logout invalidates session', async function(assert) {
88
- const stateToken = await getValidState(endpoint);
89
- const callbackResponse = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state=${stateToken}`, { redirect: 'manual' });
90
- const location = callbackResponse.headers.get('location');
91
- const sessionId = new URL(location).searchParams.get('sessionId');
92
-
93
- // Logout
94
- const logoutResponse = await fetch(`${endpoint}/auth/logout`, {
95
- headers: { 'session-id': sessionId },
96
- });
97
- assert.equal(logoutResponse.status, 200);
98
-
99
- // Verify session is invalid
100
- const authResponse = await fetch(`${endpoint}/auth`, {
101
- headers: { 'session-id': sessionId },
102
- });
103
- assert.equal(authResponse.status, 401);
104
- });
105
-
106
- test('GET /auth/callback/mock rejects missing state token', async function(assert) {
107
- const response = await fetch(`${endpoint}/auth/callback/mock?code=test-auth-code`, { redirect: 'manual' });
108
-
109
- assert.equal(response.status, 302);
110
- const location = response.headers.get('location');
111
- const redirectUrl = new URL(location);
112
- assert.equal(redirectUrl.searchParams.get('error'), 'auth_failed');
113
- });
114
-
115
- test('GET /auth/callback/mock rejects invalid state token', async function(assert) {
116
- const response = await fetch(`${endpoint}/auth/callback/mock?code=test-auth-code&state=bogus-state`, { redirect: 'manual' });
117
-
118
- assert.equal(response.status, 302);
119
- const location = response.headers.get('location');
120
- const redirectUrl = new URL(location);
121
- assert.equal(redirectUrl.searchParams.get('error'), 'auth_failed');
122
- });
123
-
124
- test('GET /auth/callback/mock with error param redirects with error', async function(assert) {
125
- const response = await fetch(`${endpoint}/auth/callback/mock?error=access_denied`, { redirect: 'manual' });
126
-
127
- assert.equal(response.status, 302);
128
- const location = response.headers.get('location');
129
- const redirectUrl = new URL(location);
130
- assert.equal(redirectUrl.origin + redirectUrl.pathname, 'http://localhost:4200/auth/callback');
131
- assert.equal(redirectUrl.searchParams.get('error'), 'access_denied');
132
- });
133
-
134
- test('GET /auth/callback/mock state token cannot be reused', async function(assert) {
135
- const stateToken = await getValidState(endpoint);
136
-
137
- // First use succeeds
138
- const first = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state=${stateToken}`, { redirect: 'manual' });
139
- assert.equal(first.status, 302);
140
- const firstLocation = new URL(first.headers.get('location'));
141
- assert.ok(firstLocation.searchParams.get('sessionId'), 'first use succeeds');
142
-
143
- // Second use fails
144
- const second = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state=${stateToken}`, { redirect: 'manual' });
145
- assert.equal(second.status, 302);
146
- const secondLocation = new URL(second.headers.get('location'));
147
- assert.equal(secondLocation.searchParams.get('error'), 'auth_failed', 'reuse is rejected');
148
- });
149
- });