kerliix-oauth 1.0.1 → 1.0.3
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/build.yml +35 -0
- package/README.md +2 -0
- package/changelog.md +55 -2
- package/dist/cache.js +1 -2
- package/dist/client.d.ts +6 -2
- package/dist/client.js +107 -91
- package/dist/types.d.ts +4 -0
- package/dist/types.js +8 -1
- package/package.json +1 -1
- package/src/cache.ts +1 -2
- package/src/client.ts +127 -96
- package/src/types.ts +10 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Build
|
|
2
|
+
|
|
3
|
+
# Run workflow on pushes and pull requests to main
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [ main ]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [ main ]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
name: Build and Test
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
strategy:
|
|
16
|
+
matrix:
|
|
17
|
+
node-version: [18, 20] # Test against Node 18 and 20
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout repository
|
|
21
|
+
uses: actions/checkout@v3
|
|
22
|
+
|
|
23
|
+
- name: Setup Node.js
|
|
24
|
+
uses: actions/setup-node@v3
|
|
25
|
+
with:
|
|
26
|
+
node-version: ${{ matrix.node-version }}
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: npm install
|
|
30
|
+
|
|
31
|
+
- name: Build TypeScript
|
|
32
|
+
run: npm run build
|
|
33
|
+
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: npm test
|
package/README.md
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
> Official Kerliix OAuth 2.0 SDK for Node.js and Browser
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/kerliix-oauth)
|
|
6
|
+
[](https://www.npmjs.com/package/kerliix-oauth)
|
|
6
7
|
[](https://github.com/kerliix-corp/kerliix-oauth-nodejs/blob/main/LICENSE)
|
|
7
8
|
[](https://github.com/kerliix-corp/kerliix-oauth-nodejs/actions)
|
|
9
|
+
[](https://www.npmjs.com/package/kerliix-oauth)
|
|
8
10
|
|
|
9
11
|
---
|
|
10
12
|
|
package/changelog.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
13
|
- Exported functions with TypeScript types (e.g., `code: string`) will throw syntax errors if run uncompiled.
|
|
14
14
|
- Always handle runtime errors from SDK methods:
|
|
15
15
|
- `"Missing required config: clientId, redirectUri, or baseUrl"`
|
|
16
|
-
- `"Token exchange failed: <statusText>"`
|
|
16
|
+
- `"Token exchange failed: <status> <statusText>. Response: <body>"`
|
|
17
17
|
- `"Failed to refresh token"`
|
|
18
18
|
- `"Missing access token"`
|
|
19
19
|
- `"Failed to fetch user info"`
|
|
@@ -22,6 +22,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## [1.3.0] - 2025-10-28
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **TypeScript client wrapper (`client.ts`)** for Node.js, replacing raw JS SDK initialization.
|
|
29
|
+
- Automatically uses **default OAuth server URL (`https://api.kerliix.com`)** if none is provided.
|
|
30
|
+
- Maintains helper functions for OAuth flows:
|
|
31
|
+
- `getAuthorizeUrl(state?: string)` – generates authorization URLs with optional PKCE.
|
|
32
|
+
- `exchangeCodeForTokens(code: string)` – exchanges authorization codes for access/refresh tokens.
|
|
33
|
+
- `fetchUserInfo(accessToken?: string)` – fetches authenticated user profile; auto-refreshes tokens if expired.
|
|
34
|
+
- `refreshTokenIfNeeded()` – refreshes tokens automatically when expired.
|
|
35
|
+
- `revokeToken(token: string)` – revokes access or refresh tokens.
|
|
36
|
+
- **Client authentication middleware** (`middlewares/clientAuth.js`) for validating Basic Auth with clientId/clientSecret.
|
|
37
|
+
- Attaches verified client object to `req.oauth2.client` and `req.user`.
|
|
38
|
+
- Returns descriptive errors for missing credentials or mismatched secrets.
|
|
39
|
+
- Includes logging for missing clients or secret mismatches.
|
|
40
|
+
- **Enhanced PKCE support**:
|
|
41
|
+
- Secure code verifier and code challenge generation for public clients.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- Migration from JS-only SDK usage to **TypeScript-based client initialization**.
|
|
45
|
+
- Client initialization no longer requires `baseUrl`; defaults to `http://api.kerliix.com`.
|
|
46
|
+
- Improved logging throughout the SDK for all OAuth operations.
|
|
47
|
+
- Internal request handling now aligns with standard OAuth 2.0 Authorization Code Flow.
|
|
48
|
+
- Token caching and refresh logic remain consistent with OAuth 2.0 standards.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- Fixed minor issues in PKCE encoding to ensure compatibility with OAuth servers expecting base64url-encoded SHA-256 challenges.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## [1.0.2] - 2025-10-27
|
|
56
|
+
### Added
|
|
57
|
+
- **`checkAuthorizationUrl()` method** to pre-validate client ID and redirect URI against the OAuth server.
|
|
58
|
+
- Sends a `HEAD` request and checks for errors without triggering full redirects.
|
|
59
|
+
- Logs success or throws descriptive errors for misconfiguration.
|
|
60
|
+
- Enhanced **error details** in `exchangeCodeForToken()`:
|
|
61
|
+
- Includes HTTP status, status text, and server response body when available.
|
|
62
|
+
- Improved logging across all SDK methods to show `err.message` for clarity.
|
|
63
|
+
- Better **developer insight** for debugging OAuth integration issues.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- Minor refactor for more readable error logging (`err.message || err`) in:
|
|
67
|
+
- `exchangeCodeForToken()`
|
|
68
|
+
- `refreshTokenIfNeeded()`
|
|
69
|
+
- `getUserInfo()`
|
|
70
|
+
- `revokeToken()`
|
|
71
|
+
- Documentation and examples updated to highlight new `checkAuthorizationUrl()` usage.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
- No functional bugs fixed; updates are focused on **developer experience and diagnostics**.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
25
78
|
## [1.0.1] - 2025-10-27
|
|
26
79
|
### Added
|
|
27
80
|
- **Runtime error documentation** for the SDK:
|
package/dist/cache.js
CHANGED
|
@@ -12,8 +12,7 @@ export class TokenCache {
|
|
|
12
12
|
const now = Math.floor(Date.now() / 1000);
|
|
13
13
|
const expiry = (this.tokenData.created_at || 0) + (this.tokenData.expires_in || 0);
|
|
14
14
|
if (now >= expiry - 30) {
|
|
15
|
-
|
|
16
|
-
return null;
|
|
15
|
+
return null; // expired or near expiry
|
|
17
16
|
}
|
|
18
17
|
return this.tokenData;
|
|
19
18
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -3,9 +3,13 @@ export default class KerliixOAuth {
|
|
|
3
3
|
private config;
|
|
4
4
|
private cache;
|
|
5
5
|
constructor(config: KerliixConfig);
|
|
6
|
-
getAuthUrl(scopes?: string[], state?: string):
|
|
7
|
-
|
|
6
|
+
getAuthUrl(scopes?: string[], state?: string, usePKCE?: boolean): Promise<{
|
|
7
|
+
url: string;
|
|
8
|
+
codeVerifier?: string;
|
|
9
|
+
}>;
|
|
10
|
+
exchangeCodeForToken(code: string, codeVerifier?: string): Promise<TokenResponse>;
|
|
8
11
|
refreshTokenIfNeeded(): Promise<TokenResponse | null>;
|
|
9
12
|
getUserInfo(accessToken?: string): Promise<UserInfo>;
|
|
10
13
|
revokeToken(token: string): Promise<boolean>;
|
|
14
|
+
private handleFetchResponse;
|
|
11
15
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,116 +1,132 @@
|
|
|
1
1
|
import { TokenCache } from "./cache.js";
|
|
2
|
+
import { OAuthError } from "./types.js";
|
|
3
|
+
// --- PKCE helpers ---
|
|
4
|
+
function base64URLEncode(buffer) {
|
|
5
|
+
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
6
|
+
.replace(/\+/g, "-")
|
|
7
|
+
.replace(/\//g, "_")
|
|
8
|
+
.replace(/=+$/, "");
|
|
9
|
+
}
|
|
10
|
+
async function sha256(plain) {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const data = encoder.encode(plain);
|
|
13
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
14
|
+
return base64URLEncode(hash);
|
|
15
|
+
}
|
|
16
|
+
// --- KerliixOAuth class ---
|
|
2
17
|
export default class KerliixOAuth {
|
|
3
18
|
constructor(config) {
|
|
4
|
-
if (!config.clientId || !config.redirectUri
|
|
5
|
-
throw new Error("Missing required config: clientId
|
|
19
|
+
if (!config.clientId || !config.redirectUri) {
|
|
20
|
+
throw new Error("Missing required config: clientId or redirectUri");
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_BASE_URL = "https://api.kerliix.com";
|
|
6
23
|
this.config = {
|
|
7
24
|
...config,
|
|
8
|
-
baseUrl: config.baseUrl.replace(/\/$/, "")
|
|
25
|
+
baseUrl: (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
9
26
|
};
|
|
10
27
|
this.cache = new TokenCache();
|
|
28
|
+
console.log("OAuth server URL:", this.config.baseUrl); // optional logging
|
|
11
29
|
}
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
// --- Generate Authorization URL ---
|
|
31
|
+
async getAuthUrl(scopes = ["openid", "profile", "email"], state = "", usePKCE = false) {
|
|
32
|
+
const params = {
|
|
14
33
|
client_id: this.config.clientId,
|
|
15
34
|
redirect_uri: this.config.redirectUri,
|
|
16
35
|
response_type: "code",
|
|
17
36
|
scope: scopes.join(" "),
|
|
18
|
-
state
|
|
19
|
-
}
|
|
20
|
-
|
|
37
|
+
state,
|
|
38
|
+
};
|
|
39
|
+
let codeVerifier;
|
|
40
|
+
if (usePKCE) {
|
|
41
|
+
codeVerifier = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
42
|
+
.map((b) => ("00" + b.toString(16)).slice(-2))
|
|
43
|
+
.join("");
|
|
44
|
+
const codeChallenge = await sha256(codeVerifier);
|
|
45
|
+
params["code_challenge"] = codeChallenge;
|
|
46
|
+
params["code_challenge_method"] = "S256";
|
|
47
|
+
}
|
|
48
|
+
const url = `${this.config.baseUrl}/oauth/authorize?${new URLSearchParams(params).toString()}`;
|
|
49
|
+
return { url, codeVerifier };
|
|
21
50
|
}
|
|
22
|
-
|
|
51
|
+
// --- Exchange code for tokens ---
|
|
52
|
+
async exchangeCodeForToken(code, codeVerifier) {
|
|
23
53
|
if (!code)
|
|
24
|
-
throw new
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
body: JSON.stringify({
|
|
30
|
-
grant_type: "authorization_code",
|
|
31
|
-
code,
|
|
32
|
-
redirect_uri: this.config.redirectUri,
|
|
33
|
-
client_id: this.config.clientId,
|
|
34
|
-
client_secret: this.config.clientSecret
|
|
35
|
-
})
|
|
36
|
-
});
|
|
37
|
-
if (!res.ok) {
|
|
38
|
-
throw new Error(`Token exchange failed: ${res.statusText}`);
|
|
39
|
-
}
|
|
40
|
-
const token = await res.json();
|
|
41
|
-
this.cache.set(token);
|
|
42
|
-
return token;
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
console.error("exchangeCodeForToken error:", err);
|
|
46
|
-
throw err;
|
|
54
|
+
throw new OAuthError("invalid_request", "Authorization code is required");
|
|
55
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
56
|
+
if (this.config.clientSecret) {
|
|
57
|
+
const basicAuth = btoa(`${this.config.clientId}:${this.config.clientSecret}`);
|
|
58
|
+
headers["Authorization"] = `Basic ${basicAuth}`;
|
|
47
59
|
}
|
|
60
|
+
const body = new URLSearchParams({
|
|
61
|
+
grant_type: "authorization_code",
|
|
62
|
+
code,
|
|
63
|
+
redirect_uri: this.config.redirectUri,
|
|
64
|
+
});
|
|
65
|
+
if (codeVerifier)
|
|
66
|
+
body.set("code_verifier", codeVerifier);
|
|
67
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/token`, { method: "POST", headers, body });
|
|
68
|
+
const data = await this.handleFetchResponse(res, "token_exchange_failed");
|
|
69
|
+
this.cache.set(data);
|
|
70
|
+
return data;
|
|
48
71
|
}
|
|
72
|
+
// --- Refresh token ---
|
|
49
73
|
async refreshTokenIfNeeded() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
throw new Error("Failed to refresh token");
|
|
69
|
-
const token = await res.json();
|
|
70
|
-
this.cache.set(token);
|
|
71
|
-
return token;
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
console.error("refreshTokenIfNeeded error:", err);
|
|
75
|
-
return null; // Fail gracefully, user may need re-auth
|
|
76
|
-
}
|
|
74
|
+
const cached = this.cache.get();
|
|
75
|
+
if (cached)
|
|
76
|
+
return cached;
|
|
77
|
+
const last = this.cache["tokenData"];
|
|
78
|
+
if (!last?.refresh_token || !this.config.clientSecret)
|
|
79
|
+
return null;
|
|
80
|
+
const headers = {
|
|
81
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
82
|
+
"Authorization": `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
83
|
+
};
|
|
84
|
+
const body = new URLSearchParams({
|
|
85
|
+
grant_type: "refresh_token",
|
|
86
|
+
refresh_token: last.refresh_token,
|
|
87
|
+
});
|
|
88
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/token`, { method: "POST", headers, body });
|
|
89
|
+
const data = await this.handleFetchResponse(res, "refresh_failed");
|
|
90
|
+
this.cache.set(data);
|
|
91
|
+
return data;
|
|
77
92
|
}
|
|
93
|
+
// --- Get user info ---
|
|
78
94
|
async getUserInfo(accessToken) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
85
|
-
});
|
|
86
|
-
if (!res.ok)
|
|
87
|
-
throw new Error("Failed to fetch user info");
|
|
88
|
-
return res.json();
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
console.error("getUserInfo error:", err);
|
|
92
|
-
throw err;
|
|
93
|
-
}
|
|
95
|
+
const token = accessToken || (await this.refreshTokenIfNeeded())?.access_token;
|
|
96
|
+
if (!token)
|
|
97
|
+
throw new OAuthError("missing_token", "No access token available");
|
|
98
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/userinfo`, { headers: { Authorization: `Bearer ${token}` } });
|
|
99
|
+
return (await this.handleFetchResponse(res, "userinfo_fetch_failed"));
|
|
94
100
|
}
|
|
101
|
+
// --- Revoke token ---
|
|
95
102
|
async revokeToken(token) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
if (!token)
|
|
104
|
+
throw new OAuthError("invalid_request", "Token is required");
|
|
105
|
+
if (!this.config.clientSecret)
|
|
106
|
+
throw new OAuthError("unauthorized_client", "Client secret required");
|
|
107
|
+
const headers = {
|
|
108
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
109
|
+
"Authorization": `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
110
|
+
};
|
|
111
|
+
const body = new URLSearchParams({ token });
|
|
112
|
+
await this.handleFetchResponse(await fetch(`${this.config.baseUrl}/oauth/revoke`, { method: "POST", headers, body }), "revoke_failed");
|
|
113
|
+
this.cache.clear();
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// --- Centralized fetch response handler ---
|
|
117
|
+
async handleFetchResponse(res, defaultError = "unknown_error") {
|
|
118
|
+
const contentType = res.headers.get("content-type") || "";
|
|
119
|
+
let data = {};
|
|
120
|
+
if (contentType.includes("application/json")) {
|
|
121
|
+
data = await res.json().catch(() => ({}));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const text = await res.text().catch(() => "");
|
|
125
|
+
data = { error: defaultError, error_description: text };
|
|
109
126
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
this.cache.clear();
|
|
113
|
-
return false;
|
|
127
|
+
if (!res.ok || data.error) {
|
|
128
|
+
throw new OAuthError(data.error || defaultError, data.error_description || "No description");
|
|
114
129
|
}
|
|
130
|
+
return data;
|
|
115
131
|
}
|
|
116
132
|
}
|
package/dist/types.d.ts
CHANGED
package/dist/types.js
CHANGED
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -15,8 +15,7 @@ export class TokenCache {
|
|
|
15
15
|
const expiry = (this.tokenData.created_at || 0) + (this.tokenData.expires_in || 0);
|
|
16
16
|
|
|
17
17
|
if (now >= expiry - 30) {
|
|
18
|
-
|
|
19
|
-
return null;
|
|
18
|
+
return null; // expired or near expiry
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
return this.tokenData;
|
package/src/client.ts
CHANGED
|
@@ -1,132 +1,163 @@
|
|
|
1
1
|
import type { KerliixConfig, TokenResponse, UserInfo } from "./types.js";
|
|
2
2
|
import { TokenCache } from "./cache.js";
|
|
3
|
+
import { OAuthError } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// --- PKCE helpers ---
|
|
6
|
+
function base64URLEncode(buffer: ArrayBuffer): string {
|
|
7
|
+
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
8
|
+
.replace(/\+/g, "-")
|
|
9
|
+
.replace(/\//g, "_")
|
|
10
|
+
.replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function sha256(plain: string): Promise<string> {
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const data = encoder.encode(plain);
|
|
16
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
17
|
+
return base64URLEncode(hash);
|
|
18
|
+
}
|
|
3
19
|
|
|
20
|
+
// --- KerliixOAuth class ---
|
|
4
21
|
export default class KerliixOAuth {
|
|
5
22
|
private config: KerliixConfig;
|
|
6
23
|
private cache: TokenCache;
|
|
7
24
|
|
|
8
25
|
constructor(config: KerliixConfig) {
|
|
9
|
-
if (!config.clientId || !config.redirectUri
|
|
10
|
-
throw new Error("Missing required config: clientId
|
|
26
|
+
if (!config.clientId || !config.redirectUri) {
|
|
27
|
+
throw new Error("Missing required config: clientId or redirectUri");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_BASE_URL = "https://api.kerliix.com";
|
|
11
31
|
|
|
12
32
|
this.config = {
|
|
13
33
|
...config,
|
|
14
|
-
baseUrl: config.baseUrl.replace(/\/$/, "")
|
|
34
|
+
baseUrl: (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
15
35
|
};
|
|
36
|
+
|
|
16
37
|
this.cache = new TokenCache();
|
|
38
|
+
console.log("OAuth server URL:", this.config.baseUrl); // optional logging
|
|
17
39
|
}
|
|
18
40
|
|
|
19
|
-
|
|
20
|
-
|
|
41
|
+
// --- Generate Authorization URL ---
|
|
42
|
+
async getAuthUrl(
|
|
43
|
+
scopes: string[] = ["openid", "profile", "email"],
|
|
44
|
+
state = "",
|
|
45
|
+
usePKCE = false
|
|
46
|
+
): Promise<{ url: string; codeVerifier?: string }> {
|
|
47
|
+
const params: Record<string, string> = {
|
|
21
48
|
client_id: this.config.clientId,
|
|
22
49
|
redirect_uri: this.config.redirectUri,
|
|
23
50
|
response_type: "code",
|
|
24
51
|
scope: scopes.join(" "),
|
|
25
|
-
state
|
|
26
|
-
}
|
|
27
|
-
|
|
52
|
+
state,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let codeVerifier: string | undefined;
|
|
56
|
+
|
|
57
|
+
if (usePKCE) {
|
|
58
|
+
codeVerifier = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
59
|
+
.map((b) => ("00" + b.toString(16)).slice(-2))
|
|
60
|
+
.join("");
|
|
61
|
+
const codeChallenge = await sha256(codeVerifier);
|
|
62
|
+
params["code_challenge"] = codeChallenge;
|
|
63
|
+
params["code_challenge_method"] = "S256";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const url = `${this.config.baseUrl}/oauth/authorize?${new URLSearchParams(params).toString()}`;
|
|
67
|
+
return { url, codeVerifier };
|
|
28
68
|
}
|
|
29
69
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
code,
|
|
40
|
-
redirect_uri: this.config.redirectUri,
|
|
41
|
-
client_id: this.config.clientId,
|
|
42
|
-
client_secret: this.config.clientSecret
|
|
43
|
-
})
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
if (!res.ok) {
|
|
47
|
-
throw new Error(`Token exchange failed: ${res.statusText}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const token = await res.json() as TokenResponse;
|
|
51
|
-
this.cache.set(token);
|
|
52
|
-
return token;
|
|
53
|
-
|
|
54
|
-
} catch (err: any) {
|
|
55
|
-
console.error("exchangeCodeForToken error:", err);
|
|
56
|
-
throw err;
|
|
70
|
+
// --- Exchange code for tokens ---
|
|
71
|
+
async exchangeCodeForToken(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
|
72
|
+
if (!code) throw new OAuthError("invalid_request", "Authorization code is required");
|
|
73
|
+
|
|
74
|
+
const headers: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
75
|
+
|
|
76
|
+
if (this.config.clientSecret) {
|
|
77
|
+
const basicAuth = btoa(`${this.config.clientId}:${this.config.clientSecret}`);
|
|
78
|
+
headers["Authorization"] = `Basic ${basicAuth}`;
|
|
57
79
|
}
|
|
80
|
+
|
|
81
|
+
const body = new URLSearchParams({
|
|
82
|
+
grant_type: "authorization_code",
|
|
83
|
+
code,
|
|
84
|
+
redirect_uri: this.config.redirectUri,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (codeVerifier) body.set("code_verifier", codeVerifier);
|
|
88
|
+
|
|
89
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/token`, { method: "POST", headers, body });
|
|
90
|
+
const data = await this.handleFetchResponse(res, "token_exchange_failed");
|
|
91
|
+
|
|
92
|
+
this.cache.set(data as TokenResponse);
|
|
93
|
+
return data as TokenResponse;
|
|
58
94
|
}
|
|
59
95
|
|
|
96
|
+
// --- Refresh token ---
|
|
60
97
|
async refreshTokenIfNeeded(): Promise<TokenResponse | null> {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const token = await res.json() as TokenResponse;
|
|
82
|
-
this.cache.set(token);
|
|
83
|
-
return token;
|
|
84
|
-
|
|
85
|
-
} catch (err: any) {
|
|
86
|
-
console.error("refreshTokenIfNeeded error:", err);
|
|
87
|
-
return null; // Fail gracefully, user may need re-auth
|
|
88
|
-
}
|
|
98
|
+
const cached = this.cache.get();
|
|
99
|
+
if (cached) return cached;
|
|
100
|
+
|
|
101
|
+
const last = this.cache["tokenData"];
|
|
102
|
+
if (!last?.refresh_token || !this.config.clientSecret) return null;
|
|
103
|
+
|
|
104
|
+
const headers = {
|
|
105
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
106
|
+
"Authorization": `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
107
|
+
};
|
|
108
|
+
const body = new URLSearchParams({
|
|
109
|
+
grant_type: "refresh_token",
|
|
110
|
+
refresh_token: last.refresh_token,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/token`, { method: "POST", headers, body });
|
|
114
|
+
const data = await this.handleFetchResponse(res, "refresh_failed");
|
|
115
|
+
|
|
116
|
+
this.cache.set(data as TokenResponse);
|
|
117
|
+
return data as TokenResponse;
|
|
89
118
|
}
|
|
90
119
|
|
|
120
|
+
// --- Get user info ---
|
|
91
121
|
async getUserInfo(accessToken?: string): Promise<UserInfo> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
122
|
+
const token = accessToken || (await this.refreshTokenIfNeeded())?.access_token;
|
|
123
|
+
if (!token) throw new OAuthError("missing_token", "No access token available");
|
|
124
|
+
|
|
125
|
+
const res = await fetch(`${this.config.baseUrl}/oauth/userinfo`, { headers: { Authorization: `Bearer ${token}` } });
|
|
126
|
+
return (await this.handleFetchResponse(res, "userinfo_fetch_failed")) as UserInfo;
|
|
127
|
+
}
|
|
95
128
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
// --- Revoke token ---
|
|
130
|
+
async revokeToken(token: string): Promise<boolean> {
|
|
131
|
+
if (!token) throw new OAuthError("invalid_request", "Token is required");
|
|
132
|
+
if (!this.config.clientSecret) throw new OAuthError("unauthorized_client", "Client secret required");
|
|
99
133
|
|
|
100
|
-
|
|
101
|
-
|
|
134
|
+
const headers = {
|
|
135
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
136
|
+
"Authorization": `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
137
|
+
};
|
|
138
|
+
const body = new URLSearchParams({ token });
|
|
102
139
|
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
140
|
+
await this.handleFetchResponse(await fetch(`${this.config.baseUrl}/oauth/revoke`, { method: "POST", headers, body }), "revoke_failed");
|
|
141
|
+
this.cache.clear();
|
|
142
|
+
return true;
|
|
107
143
|
}
|
|
108
144
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
this.cache.clear();
|
|
124
|
-
return true;
|
|
125
|
-
|
|
126
|
-
} catch (err: any) {
|
|
127
|
-
console.error("revokeToken error:", err);
|
|
128
|
-
this.cache.clear();
|
|
129
|
-
return false;
|
|
145
|
+
// --- Centralized fetch response handler ---
|
|
146
|
+
private async handleFetchResponse(res: Response, defaultError = "unknown_error") {
|
|
147
|
+
const contentType = res.headers.get("content-type") || "";
|
|
148
|
+
let data: any = {};
|
|
149
|
+
|
|
150
|
+
if (contentType.includes("application/json")) {
|
|
151
|
+
data = await res.json().catch(() => ({}));
|
|
152
|
+
} else {
|
|
153
|
+
const text = await res.text().catch(() => "");
|
|
154
|
+
data = { error: defaultError, error_description: text };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!res.ok || data.error) {
|
|
158
|
+
throw new OAuthError(data.error || defaultError, data.error_description || "No description");
|
|
130
159
|
}
|
|
160
|
+
|
|
161
|
+
return data;
|
|
131
162
|
}
|
|
132
163
|
}
|
package/src/types.ts
CHANGED
|
@@ -21,3 +21,13 @@ export interface UserInfo {
|
|
|
21
21
|
picture?: string;
|
|
22
22
|
[key: string]: any;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// Custom OAuth error class for developers
|
|
26
|
+
export class OAuthError extends Error {
|
|
27
|
+
code: string;
|
|
28
|
+
constructor(code: string, message?: string) {
|
|
29
|
+
super(message || code);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = "OAuthError";
|
|
32
|
+
}
|
|
33
|
+
}
|