@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.
- package/README.md +4 -0
- package/dist/auth-request.d.ts +35 -0
- package/dist/auth-request.js +68 -0
- package/dist/main.d.ts +22 -0
- package/dist/main.js +79 -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/token-manager.d.ts +15 -0
- package/dist/token-manager.js +24 -0
- package/package.json +45 -8
- package/src/{auth-request.js → auth-request.ts} +26 -6
- package/src/{main.js → main.ts} +36 -10
- 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/token-manager.ts +35 -0
- package/src/types/node.d.ts +3 -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 +37 -0
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- 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/src/{main.js → main.ts}
RENAMED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/.github/workflows/ci.yml
DELETED
|
@@ -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
|
package/src/token-manager.js
DELETED
|
@@ -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
|
-
});
|