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.
@@ -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
  [![npm version](https://img.shields.io/npm/v/kerliix-oauth.svg?style=flat-square)](https://www.npmjs.com/package/kerliix-oauth)
6
+ [![Downloads](https://img.shields.io/npm/dt/kerliix-oauth?style=flat-square)](https://www.npmjs.com/package/kerliix-oauth)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://github.com/kerliix-corp/kerliix-oauth-nodejs/blob/main/LICENSE)
7
8
  [![Build Status](https://github.com/kerliix-corp/kerliix-oauth-nodejs/actions/workflows/build.yml/badge.svg)](https://github.com/kerliix-corp/kerliix-oauth-nodejs/actions)
9
+ [![Node.js Version](https://img.shields.io/node/v/kerliix-oauth?style=flat-square)](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.html).
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
- console.warn("TokenCache: token expired or near expiry");
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): string;
7
- exchangeCodeForToken(code: string): Promise<TokenResponse>;
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 || !config.baseUrl)
5
- throw new Error("Missing required config: clientId, redirectUri, or baseUrl");
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
- getAuthUrl(scopes = ["openid", "profile", "email"], state = "") {
13
- const params = new URLSearchParams({
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
- return `${this.config.baseUrl}/oauth/authorize?${params.toString()}`;
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
- async exchangeCodeForToken(code) {
51
+ // --- Exchange code for tokens ---
52
+ async exchangeCodeForToken(code, codeVerifier) {
23
53
  if (!code)
24
- throw new Error("exchangeCodeForToken: code is required");
25
- try {
26
- const res = await fetch(`${this.config.baseUrl}/oauth/token`, {
27
- method: "POST",
28
- headers: { "Content-Type": "application/json" },
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
- try {
51
- const cached = this.cache.get();
52
- if (cached)
53
- return cached;
54
- const last = this.cache["tokenData"];
55
- if (!last?.refresh_token)
56
- return null;
57
- const res = await fetch(`${this.config.baseUrl}/oauth/token`, {
58
- method: "POST",
59
- headers: { "Content-Type": "application/json" },
60
- body: JSON.stringify({
61
- grant_type: "refresh_token",
62
- refresh_token: last.refresh_token,
63
- client_id: this.config.clientId,
64
- client_secret: this.config.clientSecret
65
- })
66
- });
67
- if (!res.ok)
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
- try {
80
- const token = accessToken || (await this.refreshTokenIfNeeded())?.access_token;
81
- if (!token)
82
- throw new Error("Missing access token");
83
- const res = await fetch(`${this.config.baseUrl}/oauth/userinfo`, {
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
- try {
97
- const res = await fetch(`${this.config.baseUrl}/oauth/revoke`, {
98
- method: "POST",
99
- headers: { "Content-Type": "application/json" },
100
- body: JSON.stringify({ token })
101
- });
102
- if (!res.ok) {
103
- console.warn("revokeToken failed: server rejected token");
104
- this.cache.clear(); // clear cache anyway
105
- return false;
106
- }
107
- this.cache.clear();
108
- return true;
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
- catch (err) {
111
- console.error("revokeToken error:", err);
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
@@ -19,3 +19,7 @@ export interface UserInfo {
19
19
  picture?: string;
20
20
  [key: string]: any;
21
21
  }
22
+ export declare class OAuthError extends Error {
23
+ code: string;
24
+ constructor(code: string, message?: string);
25
+ }
package/dist/types.js CHANGED
@@ -1 +1,8 @@
1
- export {};
1
+ // Custom OAuth error class for developers
2
+ export class OAuthError extends Error {
3
+ constructor(code, message) {
4
+ super(message || code);
5
+ this.code = code;
6
+ this.name = "OAuthError";
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kerliix-oauth",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Official Kerliix OAuth SDK for Node.js and browser",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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
- console.warn("TokenCache: token expired or near expiry");
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 || !config.baseUrl)
10
- throw new Error("Missing required config: clientId, redirectUri, or baseUrl");
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
- getAuthUrl(scopes: string[] = ["openid", "profile", "email"], state = ""): string {
20
- const params = new URLSearchParams({
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
- return `${this.config.baseUrl}/oauth/authorize?${params.toString()}`;
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
- async exchangeCodeForToken(code: string): Promise<TokenResponse> {
31
- if (!code) throw new Error("exchangeCodeForToken: code is required");
32
-
33
- try {
34
- const res = await fetch(`${this.config.baseUrl}/oauth/token`, {
35
- method: "POST",
36
- headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({
38
- grant_type: "authorization_code",
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
- try {
62
- const cached = this.cache.get();
63
- if (cached) return cached;
64
-
65
- const last = this.cache["tokenData"];
66
- if (!last?.refresh_token) return null;
67
-
68
- const res = await fetch(`${this.config.baseUrl}/oauth/token`, {
69
- method: "POST",
70
- headers: { "Content-Type": "application/json" },
71
- body: JSON.stringify({
72
- grant_type: "refresh_token",
73
- refresh_token: last.refresh_token,
74
- client_id: this.config.clientId,
75
- client_secret: this.config.clientSecret
76
- })
77
- });
78
-
79
- if (!res.ok) throw new Error("Failed to refresh token");
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
- try {
93
- const token = accessToken || (await this.refreshTokenIfNeeded())?.access_token;
94
- if (!token) throw new Error("Missing access token");
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
- const res = await fetch(`${this.config.baseUrl}/oauth/userinfo`, {
97
- headers: { Authorization: `Bearer ${token}` }
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
- if (!res.ok) throw new Error("Failed to fetch user info");
101
- return res.json() as Promise<UserInfo>;
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
- } catch (err: any) {
104
- console.error("getUserInfo error:", err);
105
- throw err;
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
- async revokeToken(token: string): Promise<boolean> {
110
- try {
111
- const res = await fetch(`${this.config.baseUrl}/oauth/revoke`, {
112
- method: "POST",
113
- headers: { "Content-Type": "application/json" },
114
- body: JSON.stringify({ token })
115
- });
116
-
117
- if (!res.ok) {
118
- console.warn("revokeToken failed: server rejected token");
119
- this.cache.clear(); // clear cache anyway
120
- return false;
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
+ }