@stonyx/oauth 0.1.1-beta.17 → 0.1.1-beta.170
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 +72 -0
- package/dist/auth-request.d.ts +97 -0
- package/dist/auth-request.js +187 -0
- package/dist/main.d.ts +106 -0
- package/dist/main.js +177 -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 -9
- package/src/auth-request.ts +271 -0
- package/src/main.ts +238 -0
- 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 +19 -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 +38 -0
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/src/auth-request.js +0 -74
- package/src/main.js +0 -83
- 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
|
@@ -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,40 +4,76 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.1-beta.
|
|
7
|
+
"version": "0.1.1-beta.170",
|
|
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": "
|
|
13
|
+
"main": "dist/main.js",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
|
-
".":
|
|
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.
|
|
29
|
-
"
|
|
57
|
+
"@stonyx/events": "0.1.1-beta.54",
|
|
58
|
+
"stonyx": "0.2.3-beta.83"
|
|
30
59
|
},
|
|
31
60
|
"peerDependencies": {
|
|
32
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
33
62
|
},
|
|
34
63
|
"devDependencies": {
|
|
35
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
36
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.101",
|
|
65
|
+
"@stonyx/utils": "0.2.3-beta.26",
|
|
66
|
+
"@stonyx/logs": "1.0.1-beta.20",
|
|
67
|
+
"@types/qunit": "^2.19.13",
|
|
68
|
+
"@types/sinon": "^21.0.1",
|
|
37
69
|
"qunit": "^2.24.1",
|
|
38
|
-
"sinon": "^21.0.0"
|
|
70
|
+
"sinon": "^21.0.0",
|
|
71
|
+
"tsx": "^4.21.0",
|
|
72
|
+
"typescript": "^5.8.3"
|
|
39
73
|
},
|
|
40
74
|
"scripts": {
|
|
41
|
-
"
|
|
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'"
|
|
42
78
|
}
|
|
43
79
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
|
|
6
|
+
*
|
|
7
|
+
* The attributes below are load-bearing, not cosmetic:
|
|
8
|
+
*
|
|
9
|
+
* - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
|
|
10
|
+
* initiated by the provider. `Strict` withholds the cookie on exactly that
|
|
11
|
+
* request, breaking 100% of logins while passing every CSRF test; `None`
|
|
12
|
+
* requires `Secure` and widens exposure for no benefit.
|
|
13
|
+
* - `Path=/` — routing is case-insensitive today
|
|
14
|
+
* (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
|
|
15
|
+
* RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
|
|
16
|
+
* `/auth` silently drops the cookie on a case-varied callback and breaks
|
|
17
|
+
* login.
|
|
18
|
+
* - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_COOKIE_NAME = 'oauth_state';
|
|
21
|
+
const STATE_COOKIE_PATH = '/';
|
|
22
|
+
const STATE_COOKIE_SAME_SITE = 'lax';
|
|
23
|
+
|
|
24
|
+
interface AuthorizationRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
stateToken: string;
|
|
27
|
+
bindingValue: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OAuthInstance {
|
|
31
|
+
frontendCallbackUrl?: string;
|
|
32
|
+
stateTtl: number;
|
|
33
|
+
getSession(sessionId: string): unknown;
|
|
34
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
35
|
+
discardState(stateToken: string): void;
|
|
36
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
37
|
+
handleCallback(
|
|
38
|
+
providerName: string,
|
|
39
|
+
code: string,
|
|
40
|
+
stateToken: string,
|
|
41
|
+
bindingValues: readonly string[],
|
|
42
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
43
|
+
logout(sessionId: string): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CookieOptions {
|
|
47
|
+
httpOnly: boolean;
|
|
48
|
+
sameSite: string;
|
|
49
|
+
path: string;
|
|
50
|
+
secure: boolean;
|
|
51
|
+
maxAge?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The response object express hangs off the request.
|
|
56
|
+
*
|
|
57
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
|
|
58
|
+
* is unreachable once `state.redirect` is set (`request.ts` returns on the
|
|
59
|
+
* redirect first), so setting a cookie means reaching for `req.res`.
|
|
60
|
+
*
|
|
61
|
+
* This is a deliberate, sanctioned interim reach-around, not an accident:
|
|
62
|
+
* `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
|
|
63
|
+
* first-class header/cookie affordance to migrate onto, and it is sequenced
|
|
64
|
+
* after this fix. `setBindingCookie` fails closed if the affordance is not
|
|
65
|
+
* there, which is what contains the dependency.
|
|
66
|
+
*/
|
|
67
|
+
interface ResponseLike {
|
|
68
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
69
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface RouteRequest {
|
|
73
|
+
headers: Record<string, string | undefined>;
|
|
74
|
+
params: Record<string, string>;
|
|
75
|
+
query: Record<string, string>;
|
|
76
|
+
res?: ResponseLike;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RouteState {
|
|
80
|
+
redirect?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default class AuthRequest extends Request {
|
|
84
|
+
oauth: OAuthInstance;
|
|
85
|
+
|
|
86
|
+
constructor(oauth: OAuthInstance) {
|
|
87
|
+
super();
|
|
88
|
+
this.oauth = oauth;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
handlers = {
|
|
92
|
+
get: {
|
|
93
|
+
'/': ({ headers }: RouteRequest) => {
|
|
94
|
+
const sessionId = headers['session-id'];
|
|
95
|
+
if (!sessionId) return 401;
|
|
96
|
+
|
|
97
|
+
const user = this.oauth.getSession(sessionId);
|
|
98
|
+
if (!user) return 401;
|
|
99
|
+
|
|
100
|
+
return user;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
104
|
+
const { provider: providerName } = req.params;
|
|
105
|
+
|
|
106
|
+
let authorization: AuthorizationRequest;
|
|
107
|
+
try {
|
|
108
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
109
|
+
} catch {
|
|
110
|
+
return 404;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fail closed. A state we cannot bind to this client is exactly the
|
|
114
|
+
// defect this mechanism exists to prevent, so it is withdrawn rather
|
|
115
|
+
// than issued unbindable.
|
|
116
|
+
if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
|
|
117
|
+
this.oauth.discardState(authorization.stateToken);
|
|
118
|
+
return 500;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
state.redirect = authorization.url;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
125
|
+
const { provider: providerName } = req.params;
|
|
126
|
+
const { code, state: stateToken, error } = req.query;
|
|
127
|
+
|
|
128
|
+
if (error) {
|
|
129
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
130
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
return 400;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!code) return 400;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const session = await this.oauth.handleCallback(
|
|
140
|
+
providerName,
|
|
141
|
+
code,
|
|
142
|
+
stateToken,
|
|
143
|
+
this.readBindingCookies(req),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Cleared only here, on the success path, which is the only path that
|
|
147
|
+
// is certain to have consumed a state belonging to *this* client.
|
|
148
|
+
//
|
|
149
|
+
// Clearing on failure instead looks harmless and is not: `code` is
|
|
150
|
+
// attacker-supplied and unvalidated, so a bare `?code=1` — no
|
|
151
|
+
// knowledge of anyone's state — would delete the binding cookie of a
|
|
152
|
+
// client still sitting on the provider's consent screen, leaving
|
|
153
|
+
// their pending state untouched so nothing is detectable
|
|
154
|
+
// server-side, and their real callback then fails.
|
|
155
|
+
this.clearBindingCookie(req, providerName);
|
|
156
|
+
|
|
157
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
sessionId: session.sessionId,
|
|
160
|
+
expiresAt: String(session.expiresAt),
|
|
161
|
+
});
|
|
162
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return session;
|
|
167
|
+
} catch {
|
|
168
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
169
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
return 500;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
'/logout': ({ headers }: RouteRequest) => {
|
|
177
|
+
const sessionId = headers['session-id'];
|
|
178
|
+
if (sessionId) this.oauth.logout(sessionId);
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
185
|
+
*
|
|
186
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
187
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
188
|
+
* a round trip to.
|
|
189
|
+
*
|
|
190
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
191
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
192
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
193
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
194
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
195
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
196
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
197
|
+
*
|
|
198
|
+
* An unparseable or absent redirect URI fails secure.
|
|
199
|
+
*/
|
|
200
|
+
isSecureContext(providerName: string): boolean {
|
|
201
|
+
const redirectUri = this.oauth.redirectUriFor(providerName);
|
|
202
|
+
if (!redirectUri) return true;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return new URL(redirectUri).protocol !== 'http:';
|
|
206
|
+
} catch {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
|
|
212
|
+
return {
|
|
213
|
+
httpOnly: true,
|
|
214
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
215
|
+
path: STATE_COOKIE_PATH,
|
|
216
|
+
secure: this.isSecureContext(providerName),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
|
|
221
|
+
const { res } = req;
|
|
222
|
+
|
|
223
|
+
if (typeof res?.cookie !== 'function') {
|
|
224
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
229
|
+
...this.cookieOptions(providerName),
|
|
230
|
+
maxAge: this.oauth.stateTtl,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every value the client presented under the binding cookie's name.
|
|
238
|
+
*
|
|
239
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
240
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
241
|
+
* for any victim they can plant a same-named cookie on.
|
|
242
|
+
*/
|
|
243
|
+
readBindingCookies(req: RouteRequest): string[] {
|
|
244
|
+
const header = req.headers.cookie;
|
|
245
|
+
if (!header) return [];
|
|
246
|
+
|
|
247
|
+
const values: string[] = [];
|
|
248
|
+
|
|
249
|
+
for (const part of header.split(';')) {
|
|
250
|
+
const separator = part.indexOf('=');
|
|
251
|
+
if (separator === -1) continue;
|
|
252
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
253
|
+
|
|
254
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
255
|
+
// `encodeURIComponent` never escapes, so decoding buys nothing — and
|
|
256
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
257
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
258
|
+
// callback into a 500.
|
|
259
|
+
values.push(part.slice(separator + 1).trim());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return values;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
clearBindingCookie(req: RouteRequest, providerName: string): void {
|
|
266
|
+
const { res } = req;
|
|
267
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
268
|
+
|
|
269
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
|
|
270
|
+
}
|
|
271
|
+
}
|