@mastra/auth-clerk 1.0.2 → 1.1.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/CHANGELOG.md +36 -0
- package/dist/index.cjs +406 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +113 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +405 -157
- package/dist/index.js.map +1 -1
- package/package.json +13 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @mastra/auth-clerk
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Added full Studio authentication support for Clerk users. ([#16659](https://github.com/mastra-ai/mastra/pull/16659))
|
|
8
|
+
|
|
9
|
+
**What's new:**
|
|
10
|
+
- **Studio SSO login** — your internal team can now sign in to Mastra Studio using their Clerk accounts via OAuth 2.0/OIDC
|
|
11
|
+
- **JWT validation** — API requests with Clerk-issued JWTs are automatically validated
|
|
12
|
+
- **Session persistence** — Studio sessions are maintained with encrypted cookies (no need to log in repeatedly)
|
|
13
|
+
|
|
14
|
+
**Setup:**
|
|
15
|
+
1. Create an OAuth Application in your Clerk Dashboard
|
|
16
|
+
2. Configure the auth provider with your Clerk credentials
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { MastraAuthClerk } from '@mastra/auth-clerk';
|
|
20
|
+
|
|
21
|
+
const auth = new MastraAuthClerk({
|
|
22
|
+
jwksUri: process.env.CLERK_JWKS_URI,
|
|
23
|
+
secretKey: process.env.CLERK_SECRET_KEY,
|
|
24
|
+
publishableKey: process.env.CLERK_PUBLISHABLE_KEY,
|
|
25
|
+
// For Studio SSO login:
|
|
26
|
+
oauthClientId: process.env.CLERK_OAUTH_CLIENT_ID,
|
|
27
|
+
oauthClientSecret: process.env.CLERK_OAUTH_CLIENT_SECRET,
|
|
28
|
+
session: { cookiePassword: process.env.CLERK_COOKIE_PASSWORD },
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Note:** This release includes updates to `@mastra/core` (ISSOProvider interface now supports async getLoginUrl) and `@mastra/server` (handles async login URLs). All three packages should be updated together.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- Updated dependencies [[`de66bb0`](https://github.com/mastra-ai/mastra/commit/de66bb040570444c702ce4d8e1e228a5de2949cb), [`67bf8e2`](https://github.com/mastra-ai/mastra/commit/67bf8e206dfe583954d96015cf0d09f7ac50e45f), [`8216d05`](https://github.com/mastra-ai/mastra/commit/8216d0528d866eb9a07f5d4c87ea3bb1e1139b45), [`d18b23c`](https://github.com/mastra-ai/mastra/commit/d18b23c5e29dfc381e73e3c51fcf6c779afd1823), [`5eb94eb`](https://github.com/mastra-ai/mastra/commit/5eb94ebcf66d4e28c9e26d5821ac93379bab20a0), [`1fa3e12`](https://github.com/mastra-ai/mastra/commit/1fa3e123582b63cfe49de4ee52dc6a065e8d956a), [`f9ee2ac`](https://github.com/mastra-ai/mastra/commit/f9ee2ac661af584e61bc063ac208c9035cd752ef), [`c853d53`](https://github.com/mastra-ai/mastra/commit/c853d535d2df84ab89db1adb4c28900c54c9a2d2), [`d8df1f8`](https://github.com/mastra-ai/mastra/commit/d8df1f8e947e1966c9d4e54713df56d0d0d65226), [`9192ddb`](https://github.com/mastra-ai/mastra/commit/9192ddbced8949113b30de444cbe763f075b59f5), [`ae96523`](https://github.com/mastra-ai/mastra/commit/ae965231f562d9766b0c90c49a69fc68acaa031c), [`17d5a92`](https://github.com/mastra-ai/mastra/commit/17d5a9211aa293b4d4418de3de70dc0394d58101), [`5573693`](https://github.com/mastra-ai/mastra/commit/5573693b589822250e20dfe6cf66e9ff3bc96da8), [`ec4da8a`](https://github.com/mastra-ai/mastra/commit/ec4da8a09e0d2ab452c6ee2c786042ea826b77e5), [`adc44e1`](https://github.com/mastra-ai/mastra/commit/adc44e13c7e570b91e86b20ea7556e61d819db31), [`ed346c0`](https://github.com/mastra-ai/mastra/commit/ed346c0bee2d8496690a4e538bfba1e46894660f), [`c9ce1b2`](https://github.com/mastra-ai/mastra/commit/c9ce1b28d10871110648f9d7b6d76e880b9fa999), [`3ef01fd`](https://github.com/mastra-ai/mastra/commit/3ef01fd130b53d5bd4f828beb174e516a2eb1158), [`245a9a3`](https://github.com/mastra-ai/mastra/commit/245a9a315705fce17ddd980f78a92504b6615c4a), [`dc0b611`](https://github.com/mastra-ai/mastra/commit/dc0b6119b769bd00ee2c5df9259fb376fe63077a), [`38b5de8`](https://github.com/mastra-ai/mastra/commit/38b5de8e5d1d41a69522addf53d96f4b3a1d5bf0), [`dc0b611`](https://github.com/mastra-ai/mastra/commit/dc0b6119b769bd00ee2c5df9259fb376fe63077a), [`dd6a66e`](https://github.com/mastra-ai/mastra/commit/dd6a66ea0b32e0dea8059aec6b35d151e2c87dc4), [`d785c59`](https://github.com/mastra-ai/mastra/commit/d785c593b67fcb4cdc4fab9fdbde5f3b7665efc0), [`1fa3e12`](https://github.com/mastra-ai/mastra/commit/1fa3e123582b63cfe49de4ee52dc6a065e8d956a), [`8b984f4`](https://github.com/mastra-ai/mastra/commit/8b984f4361c202270ceb69257185c4756c9a7c56), [`bf08402`](https://github.com/mastra-ai/mastra/commit/bf084022374fa5d06ca70ed67a86dd64e379071b), [`81fe587`](https://github.com/mastra-ai/mastra/commit/81fe587275035715c1720ddf3fee0505cf053036), [`1fa3e12`](https://github.com/mastra-ai/mastra/commit/1fa3e123582b63cfe49de4ee52dc6a065e8d956a), [`403c438`](https://github.com/mastra-ai/mastra/commit/403c438e417278989ce247233d2c465b8d902cdd), [`f8ba195`](https://github.com/mastra-ai/mastra/commit/f8ba1954e27ee2b20586cc6cd9cf13c002c232f2)]:
|
|
37
|
+
- @mastra/core@1.43.0
|
|
38
|
+
|
|
3
39
|
## 1.0.2
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -2,168 +2,117 @@
|
|
|
2
2
|
|
|
3
3
|
var backend = require('@clerk/backend');
|
|
4
4
|
var auth = require('@mastra/auth');
|
|
5
|
+
var server = require('@mastra/core/server');
|
|
5
6
|
|
|
6
7
|
// src/index.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
var
|
|
10
|
-
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
var ConsoleLogger = class extends MastraLogger {
|
|
65
|
-
constructor(options = {}) {
|
|
66
|
-
super(options);
|
|
67
|
-
}
|
|
68
|
-
debug(message, ...args) {
|
|
69
|
-
if (this.level === LogLevel.DEBUG) {
|
|
70
|
-
console.info(message, ...args);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
info(message, ...args) {
|
|
74
|
-
if (this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) {
|
|
75
|
-
console.info(message, ...args);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
warn(message, ...args) {
|
|
79
|
-
if (this.level === LogLevel.WARN || this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) {
|
|
80
|
-
console.info(message, ...args);
|
|
81
|
-
}
|
|
8
|
+
var DEFAULT_COOKIE_NAME = "clerk_session";
|
|
9
|
+
var DEFAULT_COOKIE_MAX_AGE = 86400;
|
|
10
|
+
var DEFAULT_SCOPES = ["openid", "profile", "email"];
|
|
11
|
+
var SALT_LENGTH = 16;
|
|
12
|
+
var IV_LENGTH = 12;
|
|
13
|
+
async function deriveKey(password, salt, usage) {
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
|
|
16
|
+
"deriveBits",
|
|
17
|
+
"deriveKey"
|
|
18
|
+
]);
|
|
19
|
+
return crypto.subtle.deriveKey(
|
|
20
|
+
{ name: "PBKDF2", salt, iterations: 1e5, hash: "SHA-256" },
|
|
21
|
+
keyMaterial,
|
|
22
|
+
{ name: "AES-GCM", length: 256 },
|
|
23
|
+
false,
|
|
24
|
+
[usage]
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
async function encryptSession(data, password) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
30
|
+
const key = await deriveKey(password, salt, "encrypt");
|
|
31
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
32
|
+
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(JSON.stringify(data)));
|
|
33
|
+
const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(encrypted).length);
|
|
34
|
+
combined.set(salt);
|
|
35
|
+
combined.set(iv, salt.length);
|
|
36
|
+
combined.set(new Uint8Array(encrypted), salt.length + iv.length);
|
|
37
|
+
return btoa(String.fromCharCode(...combined));
|
|
38
|
+
}
|
|
39
|
+
async function decryptSession(encrypted, password) {
|
|
40
|
+
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
|
41
|
+
const salt = combined.slice(0, SALT_LENGTH);
|
|
42
|
+
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
|
43
|
+
const data = combined.slice(SALT_LENGTH + IV_LENGTH);
|
|
44
|
+
const key = await deriveKey(password, salt, "decrypt");
|
|
45
|
+
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
|
|
46
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
47
|
+
}
|
|
48
|
+
var STATE_TOKEN_EXPIRY_MS = 10 * 60 * 1e3;
|
|
49
|
+
async function hmacSign(data, secret) {
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
const keyData = encoder.encode(secret);
|
|
52
|
+
const dataBytes = encoder.encode(data);
|
|
53
|
+
const cryptoKey = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
54
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, dataBytes);
|
|
55
|
+
const sigBytes = new Uint8Array(signature);
|
|
56
|
+
return btoa(String.fromCharCode(...sigBytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
57
|
+
}
|
|
58
|
+
function timingSafeEqual(a, b) {
|
|
59
|
+
if (a.length !== b.length) return false;
|
|
60
|
+
let result = 0;
|
|
61
|
+
for (let i = 0; i < a.length; i++) {
|
|
62
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
82
63
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
return result === 0;
|
|
65
|
+
}
|
|
66
|
+
async function createStateToken(originalState, redirectUri, secret) {
|
|
67
|
+
const payload = {
|
|
68
|
+
s: originalState,
|
|
69
|
+
r: redirectUri,
|
|
70
|
+
e: Date.now() + STATE_TOKEN_EXPIRY_MS
|
|
71
|
+
};
|
|
72
|
+
const payloadB64 = btoa(JSON.stringify(payload));
|
|
73
|
+
const signature = await hmacSign(payloadB64, secret);
|
|
74
|
+
return `${payloadB64}.${signature}`;
|
|
75
|
+
}
|
|
76
|
+
async function verifyStateToken(stateToken, secret) {
|
|
77
|
+
const parts = stateToken.split(".");
|
|
78
|
+
if (parts.length !== 2) {
|
|
79
|
+
throw new Error("Invalid state token format");
|
|
87
80
|
}
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
const [payloadB64, signature] = parts;
|
|
82
|
+
const expectedSig = await hmacSign(payloadB64, secret);
|
|
83
|
+
if (!timingSafeEqual(signature, expectedSig)) {
|
|
84
|
+
throw new Error("Invalid state token signature");
|
|
90
85
|
}
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
const payload = JSON.parse(atob(payloadB64));
|
|
87
|
+
if (payload.e < Date.now()) {
|
|
88
|
+
throw new Error("State token has expired");
|
|
93
89
|
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}) {
|
|
107
|
-
this.component = component || RegisteredLogger.LLM;
|
|
108
|
-
this.name = name;
|
|
109
|
-
this.#rawConfig = rawConfig;
|
|
110
|
-
this.logger = new ConsoleLogger({ name: `${this.component} - ${this.name}` });
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Returns the raw storage configuration this primitive was created from,
|
|
114
|
-
* or undefined if it was created from code.
|
|
115
|
-
*/
|
|
116
|
-
toRawConfig() {
|
|
117
|
-
return this.#rawConfig;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Sets the raw storage configuration for this primitive.
|
|
121
|
-
* @internal
|
|
122
|
-
*/
|
|
123
|
-
__setRawConfig(rawConfig) {
|
|
124
|
-
this.#rawConfig = rawConfig;
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Set the logger for the agent
|
|
128
|
-
* @param logger
|
|
129
|
-
*/
|
|
130
|
-
__setLogger(logger) {
|
|
131
|
-
this.logger = logger;
|
|
132
|
-
if (this.component !== RegisteredLogger.LLM) {
|
|
133
|
-
this.logger.debug(`Logger updated [component=${this.component}] [name=${this.name}]`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// ../../packages/core/dist/server/index.js
|
|
139
|
-
var MastraAuthProvider = class extends MastraBase {
|
|
140
|
-
protected;
|
|
141
|
-
public;
|
|
142
|
-
constructor(options) {
|
|
143
|
-
super({ component: "AUTH", name: options?.name });
|
|
144
|
-
if (options?.authorizeUser) {
|
|
145
|
-
this.authorizeUser = options.authorizeUser.bind(this);
|
|
146
|
-
}
|
|
147
|
-
this.protected = options?.protected;
|
|
148
|
-
this.public = options?.public;
|
|
149
|
-
}
|
|
150
|
-
registerOptions(opts) {
|
|
151
|
-
if (opts?.authorizeUser) {
|
|
152
|
-
this.authorizeUser = opts.authorizeUser.bind(this);
|
|
153
|
-
}
|
|
154
|
-
if (opts?.protected) {
|
|
155
|
-
this.protected = opts.protected;
|
|
156
|
-
}
|
|
157
|
-
if (opts?.public) {
|
|
158
|
-
this.public = opts.public;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
// src/index.ts
|
|
164
|
-
var MastraAuthClerk = class extends MastraAuthProvider {
|
|
90
|
+
return { originalState: payload.s, redirectUri: payload.r };
|
|
91
|
+
}
|
|
92
|
+
function escapeRegex(str) {
|
|
93
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
}
|
|
95
|
+
function deriveFapiUrl(publishableKey) {
|
|
96
|
+
const withoutPrefix = publishableKey.replace(/^pk_(test|live)_/, "");
|
|
97
|
+
const decoded = atob(withoutPrefix);
|
|
98
|
+
const domain = decoded.replace(/\$$/, "");
|
|
99
|
+
return `https://${domain}`;
|
|
100
|
+
}
|
|
101
|
+
var MastraAuthClerk = class extends server.MastraAuthProvider {
|
|
165
102
|
clerk;
|
|
166
103
|
jwksUri;
|
|
104
|
+
publishableKey;
|
|
105
|
+
fapiUrl;
|
|
106
|
+
// SSO fields
|
|
107
|
+
oauthClientId;
|
|
108
|
+
oauthClientSecret;
|
|
109
|
+
_redirectUri;
|
|
110
|
+
scopes;
|
|
111
|
+
cookieName;
|
|
112
|
+
cookieMaxAge;
|
|
113
|
+
cookiePassword;
|
|
114
|
+
secureCookies;
|
|
115
|
+
ssoEnabled;
|
|
167
116
|
constructor(options) {
|
|
168
117
|
super({ name: options?.name ?? "clerk" });
|
|
169
118
|
const jwksUri = options?.jwksUri ?? process.env.CLERK_JWKS_URI;
|
|
@@ -175,18 +124,317 @@ var MastraAuthClerk = class extends MastraAuthProvider {
|
|
|
175
124
|
);
|
|
176
125
|
}
|
|
177
126
|
this.jwksUri = jwksUri;
|
|
127
|
+
this.publishableKey = publishableKey;
|
|
128
|
+
this.fapiUrl = deriveFapiUrl(publishableKey);
|
|
178
129
|
this.clerk = backend.createClerkClient({
|
|
179
130
|
secretKey,
|
|
180
131
|
publishableKey
|
|
181
132
|
});
|
|
133
|
+
const oauthClientId = options?.oauthClientId ?? process.env.CLERK_OAUTH_CLIENT_ID;
|
|
134
|
+
const oauthClientSecret = options?.oauthClientSecret ?? process.env.CLERK_OAUTH_CLIENT_SECRET;
|
|
135
|
+
const redirectUri = options?.redirectUri ?? process.env.CLERK_OAUTH_REDIRECT_URI;
|
|
136
|
+
const cookiePassword = options?.session?.cookiePassword ?? process.env.CLERK_COOKIE_PASSWORD ?? crypto.randomUUID() + crypto.randomUUID();
|
|
137
|
+
this.oauthClientId = oauthClientId ?? null;
|
|
138
|
+
this.oauthClientSecret = oauthClientSecret ?? null;
|
|
139
|
+
this._redirectUri = redirectUri ?? null;
|
|
140
|
+
this.scopes = options?.scopes ?? DEFAULT_SCOPES;
|
|
141
|
+
this.cookieName = options?.session?.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
142
|
+
this.cookieMaxAge = options?.session?.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE;
|
|
143
|
+
this.cookiePassword = cookiePassword;
|
|
144
|
+
this.secureCookies = options?.session?.secureCookies ?? process.env.NODE_ENV === "production";
|
|
145
|
+
this.ssoEnabled = !!(oauthClientId && oauthClientSecret);
|
|
146
|
+
if (this.ssoEnabled) {
|
|
147
|
+
if (cookiePassword.length < 32) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"Cookie password must be at least 32 characters for SSO. Set CLERK_COOKIE_PASSWORD environment variable."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!options?.session?.cookiePassword && !process.env.CLERK_COOKIE_PASSWORD) {
|
|
153
|
+
console.warn(
|
|
154
|
+
"[MastraAuthClerk] No cookie password set \u2014 using auto-generated value. Sessions will not survive restarts. Set CLERK_COOKIE_PASSWORD for production use."
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
this._attachSSOProvider();
|
|
158
|
+
this._attachSessionProvider();
|
|
159
|
+
}
|
|
182
160
|
this.registerOptions(options);
|
|
183
161
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// MastraAuthProvider Implementation
|
|
164
|
+
// ============================================================================
|
|
165
|
+
async authenticateToken(token, request) {
|
|
166
|
+
if (this.ssoEnabled && request) {
|
|
167
|
+
const sessionUser = await this.getUserFromSessionCookie(request);
|
|
168
|
+
if (sessionUser) return sessionUser;
|
|
169
|
+
}
|
|
170
|
+
if (!token || typeof token !== "string") {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const user = await auth.verifyJwks(token, this.jwksUri);
|
|
175
|
+
return user;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
187
179
|
}
|
|
188
180
|
async authorizeUser(user) {
|
|
189
|
-
return !!user.sub;
|
|
181
|
+
return !!(user.sub || user.id);
|
|
182
|
+
}
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// IUserProvider Implementation
|
|
185
|
+
// ============================================================================
|
|
186
|
+
/**
|
|
187
|
+
* Extract the bearer token from the request's Authorization header or __session cookie.
|
|
188
|
+
*/
|
|
189
|
+
extractToken(request) {
|
|
190
|
+
const authHeader = request.headers.get("Authorization");
|
|
191
|
+
if (authHeader) {
|
|
192
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
193
|
+
if (token) return token;
|
|
194
|
+
}
|
|
195
|
+
const cookie = request.headers.get("Cookie");
|
|
196
|
+
if (cookie) {
|
|
197
|
+
const match = cookie.match(/__session=([^;]+)/);
|
|
198
|
+
if (match?.[1]) return match[1];
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
async getCurrentUser(request) {
|
|
203
|
+
if (this.ssoEnabled) {
|
|
204
|
+
const sessionUser = await this.getUserFromSessionCookie(request);
|
|
205
|
+
if (sessionUser) return sessionUser;
|
|
206
|
+
}
|
|
207
|
+
const token = this.extractToken(request);
|
|
208
|
+
if (!token) return null;
|
|
209
|
+
try {
|
|
210
|
+
const payload = await this.authenticateToken(token);
|
|
211
|
+
if (!payload?.sub) return null;
|
|
212
|
+
try {
|
|
213
|
+
const clerkUser = await this.clerk.users.getUser(payload.sub);
|
|
214
|
+
return {
|
|
215
|
+
id: clerkUser.id,
|
|
216
|
+
email: clerkUser.emailAddresses?.[0]?.emailAddress,
|
|
217
|
+
name: [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || void 0,
|
|
218
|
+
avatarUrl: clerkUser.imageUrl,
|
|
219
|
+
metadata: clerkUser.publicMetadata
|
|
220
|
+
};
|
|
221
|
+
} catch {
|
|
222
|
+
return {
|
|
223
|
+
id: payload.sub,
|
|
224
|
+
email: payload.email ?? void 0,
|
|
225
|
+
name: payload.name ?? void 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async getUser(userId) {
|
|
233
|
+
try {
|
|
234
|
+
const clerkUser = await this.clerk.users.getUser(userId);
|
|
235
|
+
return {
|
|
236
|
+
id: clerkUser.id,
|
|
237
|
+
email: clerkUser.emailAddresses?.[0]?.emailAddress,
|
|
238
|
+
name: [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || void 0,
|
|
239
|
+
avatarUrl: clerkUser.imageUrl,
|
|
240
|
+
metadata: clerkUser.publicMetadata
|
|
241
|
+
};
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
getUserProfileUrl(user) {
|
|
247
|
+
return `/user/${user.id}`;
|
|
248
|
+
}
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// Helper Methods
|
|
251
|
+
// ============================================================================
|
|
252
|
+
/**
|
|
253
|
+
* Check if SSO is enabled (OAuth credentials are configured).
|
|
254
|
+
*/
|
|
255
|
+
isSSOEnabled() {
|
|
256
|
+
return this.ssoEnabled;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Get the derived Frontend API URL.
|
|
260
|
+
*/
|
|
261
|
+
getFapiUrl() {
|
|
262
|
+
return this.fapiUrl;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Build consistent cookie attribute string for set/clear operations.
|
|
266
|
+
*/
|
|
267
|
+
cookieFlags(maxAge) {
|
|
268
|
+
const flags = `Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
269
|
+
return this.secureCookies ? `${flags}; Secure` : flags;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Extract user from the encrypted SSO session cookie.
|
|
273
|
+
*/
|
|
274
|
+
async getUserFromSessionCookie(request) {
|
|
275
|
+
const cookie = "header" in request && typeof request.header === "function" ? request.header("cookie") : request.headers?.get("cookie");
|
|
276
|
+
if (!cookie) return null;
|
|
277
|
+
const match = cookie.match(new RegExp(`(?:^|;\\s*)${escapeRegex(this.cookieName)}=([^;]+)`));
|
|
278
|
+
if (!match?.[1]) return null;
|
|
279
|
+
try {
|
|
280
|
+
const sessionData = await decryptSession(decodeURIComponent(match[1]), this.cookiePassword);
|
|
281
|
+
if (sessionData.expiresAt < Date.now()) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return sessionData.user;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// Dynamic ISSOProvider attachment (only when OAuth is configured)
|
|
291
|
+
// ============================================================================
|
|
292
|
+
/**
|
|
293
|
+
* Dynamically attach ISSOProvider methods to this instance.
|
|
294
|
+
* This ensures duck-typing detection only finds these methods when SSO is configured.
|
|
295
|
+
*/
|
|
296
|
+
_attachSSOProvider() {
|
|
297
|
+
const self = this;
|
|
298
|
+
this.getLoginUrl = async function(redirectUri, state) {
|
|
299
|
+
const actualRedirectUri = redirectUri ?? self._redirectUri;
|
|
300
|
+
if (!actualRedirectUri) {
|
|
301
|
+
throw new Error("Redirect URI is required for SSO login");
|
|
302
|
+
}
|
|
303
|
+
const signedState = await createStateToken(state, actualRedirectUri, self.cookiePassword);
|
|
304
|
+
const params = new URLSearchParams({
|
|
305
|
+
client_id: self.oauthClientId,
|
|
306
|
+
response_type: "code",
|
|
307
|
+
scope: self.scopes.join(" "),
|
|
308
|
+
redirect_uri: actualRedirectUri,
|
|
309
|
+
state: signedState
|
|
310
|
+
});
|
|
311
|
+
return `${self.fapiUrl}/oauth/authorize?${params.toString()}`;
|
|
312
|
+
};
|
|
313
|
+
this.handleCallback = async function(code, stateToken) {
|
|
314
|
+
const { redirectUri } = await verifyStateToken(stateToken, self.cookiePassword);
|
|
315
|
+
const tokenResponse = await fetch(`${self.fapiUrl}/oauth/token`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
319
|
+
Authorization: `Basic ${btoa(`${self.oauthClientId}:${self.oauthClientSecret}`)}`
|
|
320
|
+
},
|
|
321
|
+
body: new URLSearchParams({
|
|
322
|
+
grant_type: "authorization_code",
|
|
323
|
+
code,
|
|
324
|
+
redirect_uri: redirectUri
|
|
325
|
+
}),
|
|
326
|
+
signal: AbortSignal.timeout(1e4)
|
|
327
|
+
// 10 second timeout
|
|
328
|
+
});
|
|
329
|
+
if (!tokenResponse.ok) {
|
|
330
|
+
const error = await tokenResponse.text();
|
|
331
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
332
|
+
}
|
|
333
|
+
const tokens = await tokenResponse.json();
|
|
334
|
+
let user;
|
|
335
|
+
if (tokens.id_token) {
|
|
336
|
+
const payload = await auth.verifyJwks(tokens.id_token, self.jwksUri);
|
|
337
|
+
user = {
|
|
338
|
+
id: payload.sub,
|
|
339
|
+
email: payload.email ?? void 0,
|
|
340
|
+
name: payload.name ?? void 0,
|
|
341
|
+
avatarUrl: payload.picture ?? void 0
|
|
342
|
+
};
|
|
343
|
+
} else {
|
|
344
|
+
const userInfoResponse = await fetch(`${self.fapiUrl}/oauth/userinfo`, {
|
|
345
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
346
|
+
signal: AbortSignal.timeout(1e4)
|
|
347
|
+
// 10 second timeout
|
|
348
|
+
});
|
|
349
|
+
if (!userInfoResponse.ok) {
|
|
350
|
+
throw new Error("Failed to fetch user info from Clerk");
|
|
351
|
+
}
|
|
352
|
+
const userInfo = await userInfoResponse.json();
|
|
353
|
+
user = {
|
|
354
|
+
id: userInfo.sub,
|
|
355
|
+
email: userInfo.email,
|
|
356
|
+
name: userInfo.name,
|
|
357
|
+
avatarUrl: userInfo.picture
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const fullUser = await self.getUser(user.id);
|
|
362
|
+
if (fullUser) {
|
|
363
|
+
user = fullUser;
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
const sessionData = {
|
|
368
|
+
user,
|
|
369
|
+
expiresAt: Date.now() + self.cookieMaxAge * 1e3
|
|
370
|
+
};
|
|
371
|
+
const encryptedSession = await encryptSession(sessionData, self.cookiePassword);
|
|
372
|
+
const cookieValue = `${self.cookieName}=${encodeURIComponent(encryptedSession)}; ${self.cookieFlags(self.cookieMaxAge)}`;
|
|
373
|
+
return {
|
|
374
|
+
user,
|
|
375
|
+
tokens: {
|
|
376
|
+
accessToken: tokens.access_token,
|
|
377
|
+
refreshToken: tokens.refresh_token,
|
|
378
|
+
idToken: tokens.id_token,
|
|
379
|
+
expiresAt: new Date(Date.now() + tokens.expires_in * 1e3)
|
|
380
|
+
},
|
|
381
|
+
cookies: [cookieValue]
|
|
382
|
+
};
|
|
383
|
+
};
|
|
384
|
+
this.getLoginButtonConfig = function() {
|
|
385
|
+
return {
|
|
386
|
+
provider: "clerk",
|
|
387
|
+
text: "Sign in with Clerk",
|
|
388
|
+
description: "Sign in using your Clerk account"
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
this.getLoginCookies = function(_state) {
|
|
392
|
+
return [];
|
|
393
|
+
};
|
|
394
|
+
this.getLogoutUrl = async function(_redirectUri, _request) {
|
|
395
|
+
return null;
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Dynamic ISessionProvider attachment (only when OAuth is configured)
|
|
400
|
+
// ============================================================================
|
|
401
|
+
/**
|
|
402
|
+
* Dynamically attach ISessionProvider methods to this instance.
|
|
403
|
+
*/
|
|
404
|
+
_attachSessionProvider() {
|
|
405
|
+
const self = this;
|
|
406
|
+
this.createSession = async function(userId, metadata) {
|
|
407
|
+
const now = /* @__PURE__ */ new Date();
|
|
408
|
+
return {
|
|
409
|
+
id: crypto.randomUUID(),
|
|
410
|
+
userId,
|
|
411
|
+
createdAt: now,
|
|
412
|
+
expiresAt: new Date(now.getTime() + self.cookieMaxAge * 1e3),
|
|
413
|
+
metadata
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
this.validateSession = async function(_sessionId) {
|
|
417
|
+
return null;
|
|
418
|
+
};
|
|
419
|
+
this.destroySession = async function(_sessionId) {
|
|
420
|
+
};
|
|
421
|
+
this.refreshSession = async function(_sessionId) {
|
|
422
|
+
return null;
|
|
423
|
+
};
|
|
424
|
+
this.getSessionIdFromRequest = function(request) {
|
|
425
|
+
const cookie = request.headers.get("Cookie");
|
|
426
|
+
if (!cookie) return null;
|
|
427
|
+
const match = cookie.match(new RegExp(`(?:^|;\\s*)${escapeRegex(self.cookieName)}=([^;]+)`));
|
|
428
|
+
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
429
|
+
};
|
|
430
|
+
this.getSessionHeaders = function(_session) {
|
|
431
|
+
return {};
|
|
432
|
+
};
|
|
433
|
+
this.getClearSessionHeaders = function() {
|
|
434
|
+
return {
|
|
435
|
+
"Set-Cookie": `${self.cookieName}=; ${self.cookieFlags(0)}`
|
|
436
|
+
};
|
|
437
|
+
};
|
|
190
438
|
}
|
|
191
439
|
};
|
|
192
440
|
|