@oglofus/auth 1.0.1 → 1.1.1
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 +33 -14
- package/dist/core/auth.d.ts +7 -6
- package/dist/core/auth.js +238 -39
- package/dist/core/utils.d.ts +1 -0
- package/dist/core/utils.js +2 -1
- package/dist/core/validators.js +1 -1
- package/dist/errors/index.d.ts +2 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/plugins/email-otp.js +31 -18
- package/dist/plugins/index.d.ts +3 -2
- package/dist/plugins/index.js +3 -2
- package/dist/plugins/magic-link.js +15 -24
- package/dist/plugins/oauth2.d.ts +8 -1
- package/dist/plugins/oauth2.js +72 -39
- package/dist/plugins/organizations.d.ts +1 -1
- package/dist/plugins/organizations.js +58 -9
- package/dist/plugins/passkey.js +22 -31
- package/dist/plugins/password.js +4 -7
- package/dist/plugins/stripe.d.ts +19 -0
- package/dist/plugins/stripe.js +583 -0
- package/dist/plugins/two-factor.js +3 -6
- package/dist/types/adapters.d.ts +58 -23
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +2 -2
- package/dist/types/model.d.ts +86 -13
- package/dist/types/plugins.d.ts +91 -5
- package/dist/types/results.d.ts +1 -1
- package/dist/types/results.js +11 -2
- package/package.json +10 -3
|
@@ -1,17 +1,31 @@
|
|
|
1
|
+
import { addSeconds, cloneWithout, createId, createNumericCode, secretHash, secretVerify } from "../core/utils.js";
|
|
2
|
+
import { ensureFields } from "../core/validators.js";
|
|
1
3
|
import { AuthError } from "../errors/index.js";
|
|
2
4
|
import { createIssue } from "../issues/index.js";
|
|
3
5
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
4
|
-
import { addSeconds, createId, createNumericCode, secretHash, secretVerify, } from "../core/utils.js";
|
|
5
|
-
import { cloneWithout } from "../core/utils.js";
|
|
6
|
-
import { ensureFields } from "../core/validators.js";
|
|
7
6
|
const PENDING_PREFIX = "pending:";
|
|
7
|
+
const EMAIL_OTP_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
|
|
8
|
+
const OTP_VERIFY_POLICY = { limit: 10, windowSeconds: 300 };
|
|
8
9
|
const isPendingUserId = (value) => value.startsWith(PENDING_PREFIX);
|
|
10
|
+
const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
|
|
9
11
|
export const emailOtpPlugin = (config) => {
|
|
10
12
|
const ttl = config.challengeTtlSeconds ?? 10 * 60;
|
|
11
13
|
const maxAttempts = config.maxAttempts ?? 5;
|
|
12
14
|
const codeLength = config.codeLength ?? 6;
|
|
13
|
-
const verifyChallenge = async (challengeId, code, now) => {
|
|
15
|
+
const verifyChallenge = async (challengeId, code, now, request, security, rateLimiter) => {
|
|
14
16
|
const challenge = await config.otp.findChallengeById(challengeId);
|
|
17
|
+
if (rateLimiter) {
|
|
18
|
+
const policy = security?.rateLimits?.otpVerify ?? OTP_VERIFY_POLICY;
|
|
19
|
+
const result = await rateLimiter.consume(request?.ip
|
|
20
|
+
? `otpVerify:ip:${request.ip}:identity:${challenge?.email ?? `challenge:${challengeId}`}`
|
|
21
|
+
: `otpVerify:identity:${challenge?.email ?? `challenge:${challengeId}`}`, policy.limit, policy.windowSeconds);
|
|
22
|
+
if (!result.allowed) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: createRateLimitedError(result.retryAfterSeconds),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
15
29
|
if (!challenge) {
|
|
16
30
|
return {
|
|
17
31
|
ok: false,
|
|
@@ -23,9 +37,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
23
37
|
if (challenge.expiresAt.getTime() <= now.getTime()) {
|
|
24
38
|
return {
|
|
25
39
|
ok: false,
|
|
26
|
-
error: new AuthError("OTP_EXPIRED", "OTP has expired.", 400, [
|
|
27
|
-
createIssue("OTP expired", ["code"]),
|
|
28
|
-
]),
|
|
40
|
+
error: new AuthError("OTP_EXPIRED", "OTP has expired.", 400, [createIssue("OTP expired", ["code"])]),
|
|
29
41
|
};
|
|
30
42
|
}
|
|
31
43
|
if (challenge.attempts >= maxAttempts) {
|
|
@@ -45,9 +57,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
45
57
|
}
|
|
46
58
|
return {
|
|
47
59
|
ok: false,
|
|
48
|
-
error: new AuthError("OTP_INVALID", "Invalid OTP code.", 400, [
|
|
49
|
-
createIssue("Invalid code", ["code"]),
|
|
50
|
-
]),
|
|
60
|
+
error: new AuthError("OTP_INVALID", "Invalid OTP code.", 400, [createIssue("Invalid code", ["code"])]),
|
|
51
61
|
};
|
|
52
62
|
}
|
|
53
63
|
const consumed = await config.otp.consumeChallenge(challengeId);
|
|
@@ -68,7 +78,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
68
78
|
return {
|
|
69
79
|
kind: "auth_method",
|
|
70
80
|
method: "email_otp",
|
|
71
|
-
version: "
|
|
81
|
+
version: "2.0.0",
|
|
72
82
|
supports: {
|
|
73
83
|
register: true,
|
|
74
84
|
},
|
|
@@ -79,6 +89,13 @@ export const emailOtpPlugin = (config) => {
|
|
|
79
89
|
createApi: (ctx) => ({
|
|
80
90
|
request: async (input, request) => {
|
|
81
91
|
const email = input.email.trim().toLowerCase();
|
|
92
|
+
if (ctx.adapters.rateLimiter) {
|
|
93
|
+
const policy = ctx.security?.rateLimits?.emailOtpRequest ?? EMAIL_OTP_REQUEST_POLICY;
|
|
94
|
+
const limited = await ctx.adapters.rateLimiter.consume(request?.ip ? `emailOtpRequest:ip:${request.ip}:identity:${email}` : `emailOtpRequest:identity:${email}`, policy.limit, policy.windowSeconds);
|
|
95
|
+
if (!limited.allowed) {
|
|
96
|
+
return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
82
99
|
const user = await ctx.adapters.users.findByEmail(email);
|
|
83
100
|
const code = createNumericCode(codeLength);
|
|
84
101
|
const challenge = await config.otp.createChallenge({
|
|
@@ -116,7 +133,7 @@ export const emailOtpPlugin = (config) => {
|
|
|
116
133
|
},
|
|
117
134
|
}),
|
|
118
135
|
register: async (ctx, input) => {
|
|
119
|
-
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
|
|
136
|
+
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
|
|
120
137
|
if (!verified.ok) {
|
|
121
138
|
return errorOperation(verified.error);
|
|
122
139
|
}
|
|
@@ -130,18 +147,14 @@ export const emailOtpPlugin = (config) => {
|
|
|
130
147
|
if (requiredError) {
|
|
131
148
|
return errorOperation(requiredError);
|
|
132
149
|
}
|
|
133
|
-
const payload = cloneWithout(input, [
|
|
134
|
-
"method",
|
|
135
|
-
"challengeId",
|
|
136
|
-
"code",
|
|
137
|
-
]);
|
|
150
|
+
const payload = cloneWithout(input, ["method", "challengeId", "code"]);
|
|
138
151
|
payload.email = verified.email;
|
|
139
152
|
payload.emailVerified = true;
|
|
140
153
|
const user = await ctx.adapters.users.create(payload);
|
|
141
154
|
return successOperation({ user });
|
|
142
155
|
},
|
|
143
156
|
authenticate: async (ctx, input) => {
|
|
144
|
-
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now());
|
|
157
|
+
const verified = await verifyChallenge(input.challengeId, input.code, ctx.now(), ctx.request, ctx.security, ctx.adapters.rateLimiter);
|
|
145
158
|
if (!verified.ok) {
|
|
146
159
|
return errorOperation(verified.error);
|
|
147
160
|
}
|
package/dist/plugins/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export * from "./password.js";
|
|
2
1
|
export * from "./email-otp.js";
|
|
3
2
|
export * from "./magic-link.js";
|
|
4
3
|
export * from "./oauth2.js";
|
|
4
|
+
export * from "./organizations.js";
|
|
5
5
|
export * from "./passkey.js";
|
|
6
|
+
export * from "./password.js";
|
|
7
|
+
export * from "./stripe.js";
|
|
6
8
|
export * from "./two-factor.js";
|
|
7
|
-
export * from "./organizations.js";
|
package/dist/plugins/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export * from "./password.js";
|
|
2
1
|
export * from "./email-otp.js";
|
|
3
2
|
export * from "./magic-link.js";
|
|
4
3
|
export * from "./oauth2.js";
|
|
4
|
+
export * from "./organizations.js";
|
|
5
5
|
export * from "./passkey.js";
|
|
6
|
+
export * from "./password.js";
|
|
7
|
+
export * from "./stripe.js";
|
|
6
8
|
export * from "./two-factor.js";
|
|
7
|
-
export * from "./organizations.js";
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
+
import { addSeconds, cloneWithout, createId, createToken, deterministicTokenHash } from "../core/utils.js";
|
|
2
|
+
import { ensureFields } from "../core/validators.js";
|
|
1
3
|
import { AuthError } from "../errors/index.js";
|
|
2
4
|
import { createIssue } from "../issues/index.js";
|
|
3
5
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { ensureFields } from "../core/validators.js";
|
|
6
|
+
const MAGIC_LINK_REQUEST_POLICY = { limit: 3, windowSeconds: 300 };
|
|
7
|
+
const createRateLimitedError = (retryAfterSeconds) => new AuthError("RATE_LIMITED", "Too many requests.", 429, [], retryAfterSeconds === undefined ? undefined : { retryAfterSeconds });
|
|
7
8
|
export const magicLinkPlugin = (config) => {
|
|
8
9
|
const ttl = config.tokenTtlSeconds ?? 15 * 60;
|
|
9
10
|
const verifyToken = async (token, now) => {
|
|
10
|
-
const
|
|
11
|
-
// `secretHash` is salted; this branch should use deterministic hash.
|
|
12
|
-
// We keep compatibility by also trying deterministic fallback below.
|
|
13
|
-
const deterministicHash = secretHash(token, "deterministic_magic_link");
|
|
14
|
-
const candidate = (await config.links.findActiveTokenByHash(deterministicHash)) ??
|
|
15
|
-
(await config.links.findActiveTokenByHash(hashed));
|
|
11
|
+
const candidate = await config.links.findActiveTokenByHash(deterministicTokenHash(token, "magic_link"));
|
|
16
12
|
if (!candidate) {
|
|
17
13
|
return {
|
|
18
14
|
ok: false,
|
|
@@ -21,15 +17,6 @@ export const magicLinkPlugin = (config) => {
|
|
|
21
17
|
]),
|
|
22
18
|
};
|
|
23
19
|
}
|
|
24
|
-
// Defensive verify for deterministic hash implementations.
|
|
25
|
-
if (!secretVerify(token, candidate.tokenHash) && candidate.tokenHash !== deterministicHash) {
|
|
26
|
-
return {
|
|
27
|
-
ok: false,
|
|
28
|
-
error: new AuthError("MAGIC_LINK_INVALID", "Invalid magic link.", 400, [
|
|
29
|
-
createIssue("Invalid token", ["token"]),
|
|
30
|
-
]),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
20
|
if (candidate.expiresAt.getTime() <= now.getTime()) {
|
|
34
21
|
return {
|
|
35
22
|
ok: false,
|
|
@@ -55,7 +42,7 @@ export const magicLinkPlugin = (config) => {
|
|
|
55
42
|
return {
|
|
56
43
|
kind: "auth_method",
|
|
57
44
|
method: "magic_link",
|
|
58
|
-
version: "
|
|
45
|
+
version: "2.0.0",
|
|
59
46
|
supports: {
|
|
60
47
|
register: true,
|
|
61
48
|
},
|
|
@@ -66,9 +53,16 @@ export const magicLinkPlugin = (config) => {
|
|
|
66
53
|
createApi: (ctx) => ({
|
|
67
54
|
request: async (input, request) => {
|
|
68
55
|
const email = input.email.trim().toLowerCase();
|
|
56
|
+
if (ctx.adapters.rateLimiter) {
|
|
57
|
+
const policy = ctx.security?.rateLimits?.magicLinkRequest ?? MAGIC_LINK_REQUEST_POLICY;
|
|
58
|
+
const limited = await ctx.adapters.rateLimiter.consume(request?.ip ? `magicLinkRequest:ip:${request.ip}:identity:${email}` : `magicLinkRequest:identity:${email}`, policy.limit, policy.windowSeconds);
|
|
59
|
+
if (!limited.allowed) {
|
|
60
|
+
return errorOperation(createRateLimitedError(limited.retryAfterSeconds));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
69
63
|
const user = await ctx.adapters.users.findByEmail(email);
|
|
70
64
|
const rawToken = createToken();
|
|
71
|
-
const tokenHash =
|
|
65
|
+
const tokenHash = deterministicTokenHash(rawToken, "magic_link");
|
|
72
66
|
const expiresAt = addSeconds(ctx.now(), ttl);
|
|
73
67
|
const token = await config.links.createToken({
|
|
74
68
|
userId: user?.id,
|
|
@@ -118,10 +112,7 @@ export const magicLinkPlugin = (config) => {
|
|
|
118
112
|
if (requiredError) {
|
|
119
113
|
return errorOperation(requiredError);
|
|
120
114
|
}
|
|
121
|
-
const payload = cloneWithout(input, [
|
|
122
|
-
"method",
|
|
123
|
-
"token",
|
|
124
|
-
]);
|
|
115
|
+
const payload = cloneWithout(input, ["method", "token"]);
|
|
125
116
|
payload.email = verified.email;
|
|
126
117
|
payload.emailVerified = true;
|
|
127
118
|
const user = await ctx.adapters.users.create(payload);
|
package/dist/plugins/oauth2.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export type ArcticAuthorizationCodeClient = {
|
|
|
14
14
|
} | {
|
|
15
15
|
validateAuthorizationCode(code: string, codeVerifier: string | null): Promise<OAuth2Tokens>;
|
|
16
16
|
};
|
|
17
|
+
export type OAuth2AuthorizationCodeExchangeInput = {
|
|
18
|
+
authorizationCode: string;
|
|
19
|
+
codeVerifier?: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
};
|
|
22
|
+
export type OAuth2AuthorizationCodeExchange = (input: OAuth2AuthorizationCodeExchangeInput) => Promise<OAuth2Tokens>;
|
|
17
23
|
export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
|
|
18
24
|
provider?: P;
|
|
19
25
|
providerUserId: string;
|
|
@@ -22,7 +28,7 @@ export type OAuth2ResolvedProfile<U extends UserBase, P extends string> = {
|
|
|
22
28
|
profile?: Partial<U>;
|
|
23
29
|
};
|
|
24
30
|
export type OAuth2ProviderConfig<U extends UserBase, P extends string> = {
|
|
25
|
-
|
|
31
|
+
exchangeAuthorizationCode: OAuth2AuthorizationCodeExchange;
|
|
26
32
|
resolveProfile: (input: {
|
|
27
33
|
provider: P;
|
|
28
34
|
tokens: OAuth2Tokens;
|
|
@@ -38,4 +44,5 @@ export type OAuth2PluginConfig<U extends UserBase, P extends string, K extends k
|
|
|
38
44
|
requiredProfileFields?: readonly K[];
|
|
39
45
|
pendingProfileTtlSeconds?: number;
|
|
40
46
|
};
|
|
47
|
+
export declare const arcticAuthorizationCodeExchange: (client: ArcticAuthorizationCodeClient) => OAuth2AuthorizationCodeExchange;
|
|
41
48
|
export declare const oauth2Plugin: <U extends UserBase, P extends string, K extends keyof U = never>(config: OAuth2PluginConfig<U, P, K>) => AuthMethodPlugin<"oauth2", OAuth2AuthenticateInput<P>, OAuth2AuthenticateInput<P>, U>;
|
package/dist/plugins/oauth2.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { ArcticFetchError, OAuth2RequestError } from "arctic";
|
|
2
|
+
import { addSeconds, createId } from "../core/utils.js";
|
|
3
|
+
import { ensureFields } from "../core/validators.js";
|
|
2
4
|
import { AuthError } from "../errors/index.js";
|
|
3
5
|
import { createIssue } from "../issues/index.js";
|
|
4
6
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
export const arcticAuthorizationCodeExchange = (client) => {
|
|
8
|
+
return async ({ authorizationCode, codeVerifier }) => {
|
|
9
|
+
const validateAuthorizationCode = client.validateAuthorizationCode;
|
|
10
|
+
return validateAuthorizationCode(authorizationCode, codeVerifier ?? null);
|
|
11
|
+
};
|
|
10
12
|
};
|
|
11
13
|
const getAccessToken = (tokens) => {
|
|
12
14
|
try {
|
|
@@ -30,12 +32,28 @@ export const oauth2Plugin = (config) => {
|
|
|
30
32
|
return {
|
|
31
33
|
kind: "auth_method",
|
|
32
34
|
method: "oauth2",
|
|
33
|
-
version: "
|
|
35
|
+
version: "2.0.0",
|
|
34
36
|
supports: {
|
|
35
37
|
register: false,
|
|
36
38
|
},
|
|
37
39
|
issueFields: {
|
|
38
|
-
authenticate: ["provider", "authorizationCode", "redirectUri", "codeVerifier"],
|
|
40
|
+
authenticate: ["provider", "authorizationCode", "redirectUri", "codeVerifier", "idempotencyKey"],
|
|
41
|
+
},
|
|
42
|
+
completePendingProfile: async (_ctx, { record, user }) => {
|
|
43
|
+
const continuation = record.continuation;
|
|
44
|
+
if (!continuation ||
|
|
45
|
+
typeof continuation.provider !== "string" ||
|
|
46
|
+
typeof continuation.providerUserId !== "string") {
|
|
47
|
+
return errorOperation(new AuthError("PLUGIN_MISCONFIGURED", "Pending OAuth2 profile is missing provider continuation metadata.", 500));
|
|
48
|
+
}
|
|
49
|
+
await config.accounts.linkAccount({
|
|
50
|
+
userId: user.id,
|
|
51
|
+
provider: continuation.provider,
|
|
52
|
+
providerUserId: continuation.providerUserId,
|
|
53
|
+
accessToken: continuation.accessToken,
|
|
54
|
+
refreshToken: continuation.refreshToken,
|
|
55
|
+
});
|
|
56
|
+
return successOperation(undefined);
|
|
39
57
|
},
|
|
40
58
|
authenticate: async (ctx, input) => {
|
|
41
59
|
const providerConfig = config.providers[input.provider];
|
|
@@ -44,6 +62,19 @@ export const oauth2Plugin = (config) => {
|
|
|
44
62
|
createIssue("Provider disabled", ["provider"]),
|
|
45
63
|
]));
|
|
46
64
|
}
|
|
65
|
+
if (ctx.adapters.idempotency) {
|
|
66
|
+
if (!input.idempotencyKey || input.idempotencyKey.trim() === "") {
|
|
67
|
+
return errorOperation(new AuthError("INVALID_INPUT", "idempotencyKey is required for OAuth2 callbacks.", 400, [
|
|
68
|
+
createIssue("idempotencyKey is required when idempotency is enabled", ["idempotencyKey"]),
|
|
69
|
+
]));
|
|
70
|
+
}
|
|
71
|
+
const first = await ctx.adapters.idempotency.checkAndSet(`oauth2:${input.provider}:${input.idempotencyKey}`, ctx.security?.oauth2IdempotencyTtlSeconds ?? 300);
|
|
72
|
+
if (!first) {
|
|
73
|
+
return errorOperation(new AuthError("CONFLICT", "Duplicate OAuth2 callback.", 409, [
|
|
74
|
+
createIssue("Duplicate OAuth2 callback", ["idempotencyKey"]),
|
|
75
|
+
]));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
47
78
|
if ((providerConfig.pkceRequired ?? true) && !input.codeVerifier) {
|
|
48
79
|
return errorOperation(new AuthError("INVALID_INPUT", "Missing OAuth2 PKCE code verifier.", 400, [
|
|
49
80
|
createIssue("codeVerifier is required for this provider", ["codeVerifier"]),
|
|
@@ -51,7 +82,11 @@ export const oauth2Plugin = (config) => {
|
|
|
51
82
|
}
|
|
52
83
|
let tokens;
|
|
53
84
|
try {
|
|
54
|
-
tokens = await exchangeAuthorizationCode(
|
|
85
|
+
tokens = await providerConfig.exchangeAuthorizationCode({
|
|
86
|
+
authorizationCode: input.authorizationCode,
|
|
87
|
+
codeVerifier: input.codeVerifier,
|
|
88
|
+
redirectUri: input.redirectUri,
|
|
89
|
+
});
|
|
55
90
|
}
|
|
56
91
|
catch (error) {
|
|
57
92
|
if (error instanceof OAuth2RequestError) {
|
|
@@ -60,7 +95,9 @@ export const oauth2Plugin = (config) => {
|
|
|
60
95
|
]));
|
|
61
96
|
}
|
|
62
97
|
if (error instanceof ArcticFetchError) {
|
|
63
|
-
return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 provider is unreachable right now.", 502, [
|
|
98
|
+
return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 provider is unreachable right now.", 502, [
|
|
99
|
+
createIssue("Provider request failed", ["provider"]),
|
|
100
|
+
]));
|
|
64
101
|
}
|
|
65
102
|
return errorOperation(new AuthError("OAUTH2_EXCHANGE_FAILED", "OAuth2 code exchange failed.", 502));
|
|
66
103
|
}
|
|
@@ -89,30 +126,35 @@ export const oauth2Plugin = (config) => {
|
|
|
89
126
|
return successOperation({ user: linkedUser });
|
|
90
127
|
}
|
|
91
128
|
const normalizedEmail = exchanged.email?.trim().toLowerCase();
|
|
129
|
+
if (!normalizedEmail) {
|
|
130
|
+
return errorOperation(new AuthError("INVALID_INPUT", "OAuth2 provider did not return a usable email.", 400, [
|
|
131
|
+
createIssue("Email is required", ["email"]),
|
|
132
|
+
]));
|
|
133
|
+
}
|
|
92
134
|
const accessToken = getAccessToken(tokens);
|
|
93
135
|
const refreshToken = getRefreshToken(tokens);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
const continuation = {
|
|
137
|
+
provider: input.provider,
|
|
138
|
+
providerUserId: exchanged.providerUserId,
|
|
139
|
+
accessToken,
|
|
140
|
+
refreshToken,
|
|
141
|
+
};
|
|
142
|
+
const existing = await ctx.adapters.users.findByEmail(normalizedEmail);
|
|
143
|
+
if (existing) {
|
|
144
|
+
await config.accounts.linkAccount({
|
|
145
|
+
userId: existing.id,
|
|
146
|
+
provider: input.provider,
|
|
147
|
+
providerUserId: exchanged.providerUserId,
|
|
148
|
+
accessToken,
|
|
149
|
+
refreshToken,
|
|
150
|
+
});
|
|
151
|
+
return successOperation({ user: existing });
|
|
106
152
|
}
|
|
107
153
|
const profileRecord = {
|
|
108
154
|
...exchanged.profile,
|
|
155
|
+
email: normalizedEmail,
|
|
156
|
+
emailVerified: exchanged.emailVerified ?? false,
|
|
109
157
|
};
|
|
110
|
-
if (normalizedEmail) {
|
|
111
|
-
profileRecord.email = normalizedEmail;
|
|
112
|
-
}
|
|
113
|
-
if (exchanged.emailVerified !== undefined) {
|
|
114
|
-
profileRecord.emailVerified = exchanged.emailVerified;
|
|
115
|
-
}
|
|
116
158
|
const requiredError = ensureFields(profileRecord, requiredProfileFields.map(String));
|
|
117
159
|
if (requiredError) {
|
|
118
160
|
const pending = ctx.adapters.pendingProfiles;
|
|
@@ -128,7 +170,8 @@ export const oauth2Plugin = (config) => {
|
|
|
128
170
|
sourceMethod: "oauth2",
|
|
129
171
|
email: normalizedEmail,
|
|
130
172
|
missingFields,
|
|
131
|
-
prefill:
|
|
173
|
+
prefill: profileRecord,
|
|
174
|
+
continuation: continuation,
|
|
132
175
|
};
|
|
133
176
|
await pending.create({
|
|
134
177
|
...meta,
|
|
@@ -137,17 +180,7 @@ export const oauth2Plugin = (config) => {
|
|
|
137
180
|
});
|
|
138
181
|
return errorOperation(new AuthError("PROFILE_COMPLETION_REQUIRED", "Additional profile fields are required.", 400, requiredError.issues, meta), requiredError.issues);
|
|
139
182
|
}
|
|
140
|
-
|
|
141
|
-
return errorOperation(new AuthError("INVALID_INPUT", "OAuth2 provider did not return a usable email.", 400, [
|
|
142
|
-
createIssue("Email is required", ["email"]),
|
|
143
|
-
]));
|
|
144
|
-
}
|
|
145
|
-
const newUserPayload = {
|
|
146
|
-
...profileRecord,
|
|
147
|
-
email: normalizedEmail,
|
|
148
|
-
emailVerified: exchanged.emailVerified ?? false,
|
|
149
|
-
};
|
|
150
|
-
const user = await ctx.adapters.users.create(newUserPayload);
|
|
183
|
+
const user = await ctx.adapters.users.create(profileRecord);
|
|
151
184
|
await config.accounts.linkAccount({
|
|
152
185
|
userId: user.id,
|
|
153
186
|
provider: input.provider,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MembershipBase, OrganizationBase, UserBase } from "../types/model.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DomainPlugin, OrganizationsPluginApi, OrganizationsPluginConfig } from "../types/plugins.js";
|
|
3
3
|
export type OrganizationsPluginOptions<O extends OrganizationBase, Role extends string, M extends MembershipBase<Role>, Permission extends string, Feature extends string, LimitKey extends string, RequiredOrgFields extends keyof O = never> = OrganizationsPluginConfig<O, Role, M, Permission, Feature, LimitKey, RequiredOrgFields> & {
|
|
4
4
|
inviteBaseUrl: string;
|
|
5
5
|
inviteTtlSeconds?: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { addSeconds, createId, createToken, deterministicTokenHash } from "../core/utils.js";
|
|
1
2
|
import { AuthError } from "../errors/index.js";
|
|
2
3
|
import { createIssue } from "../issues/index.js";
|
|
3
4
|
import { errorOperation, successOperation } from "../types/results.js";
|
|
4
|
-
import { addSeconds, createId, createToken, secretHash } from "../core/utils.js";
|
|
5
5
|
const normalizeEmail = (value) => value.trim().toLowerCase();
|
|
6
6
|
const findOwnerRoles = (roles) => Object.entries(roles)
|
|
7
7
|
.filter(([, definition]) => definition.system?.owner)
|
|
@@ -49,7 +49,7 @@ export const organizationsPlugin = (config) => {
|
|
|
49
49
|
const plugin = {
|
|
50
50
|
kind: "domain",
|
|
51
51
|
method: "organizations",
|
|
52
|
-
version: "
|
|
52
|
+
version: "2.0.0",
|
|
53
53
|
__organizationConfig: config,
|
|
54
54
|
createApi: (ctx) => {
|
|
55
55
|
const ensureActorMembership = async (userId, organizationId) => {
|
|
@@ -75,15 +75,34 @@ export const organizationsPlugin = (config) => {
|
|
|
75
75
|
return membershipRes;
|
|
76
76
|
}
|
|
77
77
|
const resolved = resolveRole(membershipRes.data.role, config.handlers.roles);
|
|
78
|
+
const stripeApi = ctx.getPluginApi?.("stripe") ?? null;
|
|
79
|
+
let billingEntitlements = {
|
|
80
|
+
features: {},
|
|
81
|
+
limits: {},
|
|
82
|
+
};
|
|
83
|
+
if (stripeApi) {
|
|
84
|
+
const stripeEntitlements = await stripeApi.getEntitlements({
|
|
85
|
+
subject: {
|
|
86
|
+
kind: "organization",
|
|
87
|
+
organizationId,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
if (!stripeEntitlements.ok) {
|
|
91
|
+
return stripeEntitlements;
|
|
92
|
+
}
|
|
93
|
+
billingEntitlements = stripeEntitlements.data;
|
|
94
|
+
}
|
|
78
95
|
const featureOverrides = await config.handlers.entitlements.getFeatureOverrides(organizationId);
|
|
79
96
|
const limitOverrides = await config.handlers.entitlements.getLimitOverrides(organizationId);
|
|
80
97
|
return successOperation({
|
|
81
98
|
features: {
|
|
82
99
|
...resolved.features,
|
|
100
|
+
...billingEntitlements.features,
|
|
83
101
|
...featureOverrides,
|
|
84
102
|
},
|
|
85
103
|
limits: {
|
|
86
104
|
...resolved.limits,
|
|
105
|
+
...billingEntitlements.limits,
|
|
87
106
|
...limitOverrides,
|
|
88
107
|
},
|
|
89
108
|
});
|
|
@@ -118,9 +137,7 @@ export const organizationsPlugin = (config) => {
|
|
|
118
137
|
});
|
|
119
138
|
return { organization, membership };
|
|
120
139
|
};
|
|
121
|
-
const data = ctx.adapters.withTransaction
|
|
122
|
-
? await ctx.adapters.withTransaction(run)
|
|
123
|
-
: await run();
|
|
140
|
+
const data = ctx.adapters.withTransaction ? await ctx.adapters.withTransaction(run) : await run();
|
|
124
141
|
return successOperation(data);
|
|
125
142
|
},
|
|
126
143
|
inviteMember: async (input, request) => {
|
|
@@ -141,7 +158,7 @@ export const organizationsPlugin = (config) => {
|
|
|
141
158
|
return errorOperation(new AuthError("ROLE_INVALID", "Unknown role.", 400));
|
|
142
159
|
}
|
|
143
160
|
const rawToken = createToken(32);
|
|
144
|
-
const tokenHash =
|
|
161
|
+
const tokenHash = deterministicTokenHash(rawToken, "org_invite");
|
|
145
162
|
const invite = {
|
|
146
163
|
id: createId(),
|
|
147
164
|
organizationId: input.organizationId,
|
|
@@ -173,7 +190,7 @@ export const organizationsPlugin = (config) => {
|
|
|
173
190
|
});
|
|
174
191
|
},
|
|
175
192
|
acceptInvite: async (input) => {
|
|
176
|
-
const invite = await config.handlers.invites.findActiveByTokenHash(
|
|
193
|
+
const invite = await config.handlers.invites.findActiveByTokenHash(deterministicTokenHash(input.token, "org_invite"));
|
|
177
194
|
if (!invite) {
|
|
178
195
|
return errorOperation(new AuthError("ORGANIZATION_INVITE_INVALID", "Invalid invite token.", 400));
|
|
179
196
|
}
|
|
@@ -194,9 +211,17 @@ export const organizationsPlugin = (config) => {
|
|
|
194
211
|
const existing = await config.handlers.memberships.findByUserAndOrganization(input.userId, invite.organizationId);
|
|
195
212
|
let membership;
|
|
196
213
|
if (existing) {
|
|
197
|
-
|
|
214
|
+
const updatedRole = await config.handlers.memberships.setRole(existing.id, invite.role);
|
|
215
|
+
if (!updatedRole) {
|
|
216
|
+
return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
|
|
217
|
+
}
|
|
218
|
+
membership = updatedRole;
|
|
198
219
|
if (membership.status !== "active") {
|
|
199
|
-
|
|
220
|
+
const updatedStatus = await config.handlers.memberships.setStatus(membership.id, "active");
|
|
221
|
+
if (!updatedStatus) {
|
|
222
|
+
return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
|
|
223
|
+
}
|
|
224
|
+
membership = updatedStatus;
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
else {
|
|
@@ -212,6 +237,27 @@ export const organizationsPlugin = (config) => {
|
|
|
212
237
|
membership,
|
|
213
238
|
});
|
|
214
239
|
},
|
|
240
|
+
setActiveOrganization: async (input, request) => {
|
|
241
|
+
const session = await ctx.adapters.sessions.findById(input.sessionId);
|
|
242
|
+
if (!session) {
|
|
243
|
+
return errorOperation(new AuthError("SESSION_NOT_FOUND", "Session not found.", 404));
|
|
244
|
+
}
|
|
245
|
+
if (input.organizationId) {
|
|
246
|
+
const memberships = await config.handlers.memberships.listByUser(session.userId);
|
|
247
|
+
const active = memberships.find((membership) => membership.organizationId === input.organizationId && membership.status === "active");
|
|
248
|
+
if (!active) {
|
|
249
|
+
return errorOperation(new AuthError("MEMBERSHIP_FORBIDDEN", "No active membership for organization.", 403));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const updatedSession = await config.handlers.organizationSessions.setActiveOrganization(input.sessionId, input.organizationId);
|
|
253
|
+
if (!updatedSession) {
|
|
254
|
+
return errorOperation(new AuthError("SESSION_NOT_FOUND", "Session not found.", 404));
|
|
255
|
+
}
|
|
256
|
+
return successOperation({
|
|
257
|
+
sessionId: input.sessionId,
|
|
258
|
+
activeOrganizationId: updatedSession.activeOrganizationId ?? null,
|
|
259
|
+
});
|
|
260
|
+
},
|
|
215
261
|
setMemberRole: async (input, request) => {
|
|
216
262
|
const actorUserId = request?.userId;
|
|
217
263
|
if (!actorUserId) {
|
|
@@ -248,6 +294,9 @@ export const organizationsPlugin = (config) => {
|
|
|
248
294
|
}
|
|
249
295
|
}
|
|
250
296
|
const membership = await config.handlers.memberships.setRole(input.membershipId, input.role);
|
|
297
|
+
if (!membership) {
|
|
298
|
+
return errorOperation(new AuthError("MEMBERSHIP_NOT_FOUND", "Membership not found.", 404));
|
|
299
|
+
}
|
|
251
300
|
return successOperation({ membership });
|
|
252
301
|
},
|
|
253
302
|
listMemberships: async (input) => {
|