@stonyx/oauth 0.1.0 → 0.1.1-alpha.0
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/.github/workflows/publish.yml +16 -0
- package/config/environment.js +1 -0
- package/package.json +7 -6
- package/src/auth-request.js +26 -3
- package/src/main.js +17 -2
- package/test/config/environment.js +1 -0
- package/test/integration/oauth-test.js +70 -14
- package/test/unit/state-validation-test.js +118 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
name: Publish to NPM
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
+
repository_dispatch:
|
|
5
|
+
types: [cascade-publish]
|
|
4
6
|
workflow_dispatch:
|
|
5
7
|
inputs:
|
|
6
8
|
version-type:
|
|
@@ -21,6 +23,10 @@ on:
|
|
|
21
23
|
push:
|
|
22
24
|
branches: [main]
|
|
23
25
|
|
|
26
|
+
concurrency:
|
|
27
|
+
group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
|
|
28
|
+
cancel-in-progress: false
|
|
29
|
+
|
|
24
30
|
permissions:
|
|
25
31
|
contents: write
|
|
26
32
|
id-token: write
|
|
@@ -28,8 +34,18 @@ permissions:
|
|
|
28
34
|
|
|
29
35
|
jobs:
|
|
30
36
|
publish:
|
|
37
|
+
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
31
38
|
uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
|
|
32
39
|
with:
|
|
33
40
|
version-type: ${{ github.event.inputs.version-type }}
|
|
34
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 }}
|
|
35
51
|
secrets: inherit
|
package/config/environment.js
CHANGED
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.0",
|
|
7
|
+
"version": "0.1.1-alpha.0",
|
|
8
8
|
"description": "OAuth2 authentication module for the Stonyx framework",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -21,17 +21,18 @@
|
|
|
21
21
|
"Stone Costa <stone.costa@synamicd.com>"
|
|
22
22
|
],
|
|
23
23
|
"publishConfig": {
|
|
24
|
-
"access": "public"
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"stonyx": "
|
|
28
|
+
"stonyx": "0.2.3-beta.4"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
|
-
"@stonyx/rest-server": ">=0.2.
|
|
31
|
+
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
|
-
"@stonyx/rest-server": "
|
|
34
|
-
"@stonyx/utils": "
|
|
34
|
+
"@stonyx/rest-server": "0.2.1-beta.11",
|
|
35
|
+
"@stonyx/utils": "0.2.3-beta.4",
|
|
35
36
|
"qunit": "^2.24.1",
|
|
36
37
|
"sinon": "^21.0.0"
|
|
37
38
|
},
|
package/src/auth-request.js
CHANGED
|
@@ -29,15 +29,38 @@ export default class AuthRequest extends Request {
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
|
|
32
|
-
'/callback/:provider': async (req) => {
|
|
32
|
+
'/callback/:provider': async (req, state) => {
|
|
33
33
|
const { provider: providerName } = req.params;
|
|
34
|
-
const { code, state: stateToken } = req.query;
|
|
34
|
+
const { code, state: stateToken, error } = req.query;
|
|
35
|
+
|
|
36
|
+
if (error) {
|
|
37
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
38
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
return 400;
|
|
42
|
+
}
|
|
35
43
|
|
|
36
44
|
if (!code) return 400;
|
|
37
45
|
|
|
38
46
|
try {
|
|
39
|
-
|
|
47
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken);
|
|
48
|
+
|
|
49
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
50
|
+
const params = new URLSearchParams({
|
|
51
|
+
sessionId: session.sessionId,
|
|
52
|
+
expiresAt: session.expiresAt,
|
|
53
|
+
});
|
|
54
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return session;
|
|
40
59
|
} catch {
|
|
60
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
61
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
41
64
|
return 500;
|
|
42
65
|
}
|
|
43
66
|
},
|
package/src/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import AuthRequest from './auth-request.js';
|
|
|
8
8
|
|
|
9
9
|
export default class OAuth {
|
|
10
10
|
providers = new Map();
|
|
11
|
+
pendingStates = new Map();
|
|
11
12
|
|
|
12
13
|
constructor() {
|
|
13
14
|
if (OAuth.instance) return OAuth.instance;
|
|
@@ -15,7 +16,8 @@ export default class OAuth {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
async init() {
|
|
18
|
-
const { providers, sessionDuration } = config.oauth;
|
|
19
|
+
const { providers, sessionDuration, frontendCallbackUrl } = config.oauth;
|
|
20
|
+
this.frontendCallbackUrl = frontendCallbackUrl;
|
|
19
21
|
|
|
20
22
|
for (const [name, providerConfig] of Object.entries(providers)) {
|
|
21
23
|
const modulePath = providerConfig.module
|
|
@@ -43,10 +45,23 @@ export default class OAuth {
|
|
|
43
45
|
getAuthorizationUrl(providerName) {
|
|
44
46
|
const { flow } = this.getProvider(providerName);
|
|
45
47
|
const stateToken = crypto.randomUUID();
|
|
48
|
+
this.pendingStates.set(stateToken, Date.now());
|
|
46
49
|
return flow.buildAuthorizationUrl(stateToken);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
async handleCallback(providerName, code) {
|
|
52
|
+
async handleCallback(providerName, code, stateToken) {
|
|
53
|
+
if (!stateToken || !this.pendingStates.has(stateToken)) {
|
|
54
|
+
throw new Error('Invalid or missing state token');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stateCreatedAt = this.pendingStates.get(stateToken);
|
|
58
|
+
this.pendingStates.delete(stateToken);
|
|
59
|
+
|
|
60
|
+
const TEN_MINUTES = 10 * 60 * 1000;
|
|
61
|
+
if (Date.now() - stateCreatedAt > TEN_MINUTES) {
|
|
62
|
+
throw new Error('State token has expired');
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
const { flow, tokenManager } = this.getProvider(providerName);
|
|
51
66
|
const tokens = await tokenManager.getTokens(code);
|
|
52
67
|
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
@@ -2,10 +2,18 @@ import QUnit from 'qunit';
|
|
|
2
2
|
import RestServer from '@stonyx/rest-server';
|
|
3
3
|
import config from 'stonyx/config';
|
|
4
4
|
import { setupIntegrationTests } from 'stonyx/test-helpers';
|
|
5
|
+
import OAuth from '../../src/main.js';
|
|
5
6
|
|
|
6
7
|
const { module, test } = QUnit;
|
|
7
8
|
let endpoint;
|
|
8
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
|
+
|
|
9
17
|
module('[Integration] OAuth', function(hooks) {
|
|
10
18
|
setupIntegrationTests(hooks);
|
|
11
19
|
|
|
@@ -34,21 +42,24 @@ module('[Integration] OAuth', function(hooks) {
|
|
|
34
42
|
assert.equal(response.status, 404);
|
|
35
43
|
});
|
|
36
44
|
|
|
37
|
-
test('GET /auth/callback/mock
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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' });
|
|
40
48
|
|
|
41
|
-
assert.equal(response.status,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert.equal(
|
|
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');
|
|
46
56
|
});
|
|
47
57
|
|
|
48
58
|
test('GET /auth with valid session returns user', async function(assert) {
|
|
49
|
-
|
|
50
|
-
const callbackResponse = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state
|
|
51
|
-
const
|
|
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');
|
|
52
63
|
|
|
53
64
|
const response = await fetch(`${endpoint}/auth`, {
|
|
54
65
|
headers: { 'session-id': sessionId },
|
|
@@ -74,9 +85,10 @@ module('[Integration] OAuth', function(hooks) {
|
|
|
74
85
|
});
|
|
75
86
|
|
|
76
87
|
test('GET /auth/logout invalidates session', async function(assert) {
|
|
77
|
-
|
|
78
|
-
const callbackResponse = await fetch(`${endpoint}/auth/callback/mock?code=test-code&state
|
|
79
|
-
const
|
|
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');
|
|
80
92
|
|
|
81
93
|
// Logout
|
|
82
94
|
const logoutResponse = await fetch(`${endpoint}/auth/logout`, {
|
|
@@ -90,4 +102,48 @@ module('[Integration] OAuth', function(hooks) {
|
|
|
90
102
|
});
|
|
91
103
|
assert.equal(authResponse.status, 401);
|
|
92
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
|
+
});
|
|
93
149
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import QUnit from 'qunit';
|
|
2
|
+
|
|
3
|
+
const { module, test } = QUnit;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for OAuth state token validation logic.
|
|
7
|
+
*
|
|
8
|
+
* These tests exercise the pendingStates map and validation directly,
|
|
9
|
+
* mirroring the logic in OAuth.handleCallback without requiring the
|
|
10
|
+
* full module initialization (which depends on stonyx config/modules).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TEN_MINUTES = 10 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function validateState(pendingStates, stateToken) {
|
|
16
|
+
if (!stateToken || !pendingStates.has(stateToken)) {
|
|
17
|
+
throw new Error('Invalid or missing state token');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const stateCreatedAt = pendingStates.get(stateToken);
|
|
21
|
+
pendingStates.delete(stateToken);
|
|
22
|
+
|
|
23
|
+
if (Date.now() - stateCreatedAt > TEN_MINUTES) {
|
|
24
|
+
throw new Error('State token has expired');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module('[Unit] State Validation', function() {
|
|
29
|
+
test('accepts a valid pending state token', function(assert) {
|
|
30
|
+
const pendingStates = new Map();
|
|
31
|
+
pendingStates.set('valid-token', Date.now());
|
|
32
|
+
|
|
33
|
+
validateState(pendingStates, 'valid-token');
|
|
34
|
+
assert.ok(true, 'did not throw');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('consumes the state token after validation', function(assert) {
|
|
38
|
+
const pendingStates = new Map();
|
|
39
|
+
pendingStates.set('one-time-token', Date.now());
|
|
40
|
+
|
|
41
|
+
validateState(pendingStates, 'one-time-token');
|
|
42
|
+
assert.false(pendingStates.has('one-time-token'), 'token removed from pending states');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects a missing state token', function(assert) {
|
|
46
|
+
const pendingStates = new Map();
|
|
47
|
+
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => validateState(pendingStates, undefined),
|
|
50
|
+
/Invalid or missing state token/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('rejects an empty string state token', function(assert) {
|
|
55
|
+
const pendingStates = new Map();
|
|
56
|
+
|
|
57
|
+
assert.throws(
|
|
58
|
+
() => validateState(pendingStates, ''),
|
|
59
|
+
/Invalid or missing state token/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('rejects an unknown state token', function(assert) {
|
|
64
|
+
const pendingStates = new Map();
|
|
65
|
+
pendingStates.set('known-token', Date.now());
|
|
66
|
+
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => validateState(pendingStates, 'unknown-token'),
|
|
69
|
+
/Invalid or missing state token/,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('rejects an expired state token (older than 10 minutes)', function(assert) {
|
|
74
|
+
const pendingStates = new Map();
|
|
75
|
+
const elevenMinutesAgo = Date.now() - (11 * 60 * 1000);
|
|
76
|
+
pendingStates.set('expired-token', elevenMinutesAgo);
|
|
77
|
+
|
|
78
|
+
assert.throws(
|
|
79
|
+
() => validateState(pendingStates, 'expired-token'),
|
|
80
|
+
/State token has expired/,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('expired state token is still consumed', function(assert) {
|
|
85
|
+
const pendingStates = new Map();
|
|
86
|
+
const elevenMinutesAgo = Date.now() - (11 * 60 * 1000);
|
|
87
|
+
pendingStates.set('expired-token', elevenMinutesAgo);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
validateState(pendingStates, 'expired-token');
|
|
91
|
+
} catch {
|
|
92
|
+
// expected
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assert.false(pendingStates.has('expired-token'), 'expired token removed from map');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('accepts a token just under 10 minutes old', function(assert) {
|
|
99
|
+
const pendingStates = new Map();
|
|
100
|
+
const nineMinutesAgo = Date.now() - (9 * 60 * 1000);
|
|
101
|
+
pendingStates.set('fresh-token', nineMinutesAgo);
|
|
102
|
+
|
|
103
|
+
validateState(pendingStates, 'fresh-token');
|
|
104
|
+
assert.ok(true, 'did not throw');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rejects reuse of a previously valid token', function(assert) {
|
|
108
|
+
const pendingStates = new Map();
|
|
109
|
+
pendingStates.set('use-once', Date.now());
|
|
110
|
+
|
|
111
|
+
validateState(pendingStates, 'use-once');
|
|
112
|
+
|
|
113
|
+
assert.throws(
|
|
114
|
+
() => validateState(pendingStates, 'use-once'),
|
|
115
|
+
/Invalid or missing state token/,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|