@emdash-cms/auth 0.0.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/dist/adapters/kysely.d.mts +62 -0
- package/dist/adapters/kysely.d.mts.map +1 -0
- package/dist/adapters/kysely.mjs +379 -0
- package/dist/adapters/kysely.mjs.map +1 -0
- package/dist/authenticate-D5UgaoTH.d.mts +124 -0
- package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
- package/dist/authenticate-j5GayLXB.mjs +373 -0
- package/dist/authenticate-j5GayLXB.mjs.map +1 -0
- package/dist/index.d.mts +444 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +728 -0
- package/dist/index.mjs.map +1 -0
- package/dist/oauth/providers/github.d.mts +12 -0
- package/dist/oauth/providers/github.d.mts.map +1 -0
- package/dist/oauth/providers/github.mjs +55 -0
- package/dist/oauth/providers/github.mjs.map +1 -0
- package/dist/oauth/providers/google.d.mts +7 -0
- package/dist/oauth/providers/google.d.mts.map +1 -0
- package/dist/oauth/providers/google.mjs +38 -0
- package/dist/oauth/providers/google.mjs.map +1 -0
- package/dist/passkey/index.d.mts +2 -0
- package/dist/passkey/index.mjs +3 -0
- package/dist/types-Bu4irX9A.d.mts +35 -0
- package/dist/types-Bu4irX9A.d.mts.map +1 -0
- package/dist/types-CiSNpRI9.mjs +60 -0
- package/dist/types-CiSNpRI9.mjs.map +1 -0
- package/dist/types-HtRc90Wi.d.mts +208 -0
- package/dist/types-HtRc90Wi.d.mts.map +1 -0
- package/package.json +72 -0
- package/src/adapters/kysely.ts +715 -0
- package/src/config.ts +214 -0
- package/src/index.ts +135 -0
- package/src/invite.ts +205 -0
- package/src/magic-link/index.ts +150 -0
- package/src/oauth/consumer.ts +324 -0
- package/src/oauth/providers/github.ts +68 -0
- package/src/oauth/providers/google.ts +34 -0
- package/src/oauth/types.ts +36 -0
- package/src/passkey/authenticate.ts +183 -0
- package/src/passkey/index.ts +27 -0
- package/src/passkey/register.ts +232 -0
- package/src/passkey/types.ts +120 -0
- package/src/rbac.test.ts +141 -0
- package/src/rbac.ts +205 -0
- package/src/signup.ts +210 -0
- package/src/tokens.test.ts +141 -0
- package/src/tokens.ts +238 -0
- package/src/types.ts +352 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration schema for @emdash-cms/auth
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import type { RoleName } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** Matches http(s) scheme at start of URL */
|
|
10
|
+
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
|
11
|
+
|
|
12
|
+
/** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
|
|
13
|
+
const httpUrl = z
|
|
14
|
+
.string()
|
|
15
|
+
.url()
|
|
16
|
+
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OAuth provider configuration
|
|
20
|
+
*/
|
|
21
|
+
const oauthProviderSchema = z.object({
|
|
22
|
+
clientId: z.string(),
|
|
23
|
+
clientSecret: z.string(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full auth configuration schema
|
|
28
|
+
*/
|
|
29
|
+
export const authConfigSchema = z.object({
|
|
30
|
+
/**
|
|
31
|
+
* Secret key for encrypting tokens and session data.
|
|
32
|
+
* Generate with: `emdash auth secret`
|
|
33
|
+
*/
|
|
34
|
+
secret: z.string().min(32, "Auth secret must be at least 32 characters"),
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Passkey (WebAuthn) configuration
|
|
38
|
+
*/
|
|
39
|
+
passkeys: z
|
|
40
|
+
.object({
|
|
41
|
+
/**
|
|
42
|
+
* Relying party name shown to users during passkey registration
|
|
43
|
+
*/
|
|
44
|
+
rpName: z.string(),
|
|
45
|
+
/**
|
|
46
|
+
* Relying party ID (domain). Defaults to the hostname from baseUrl.
|
|
47
|
+
*/
|
|
48
|
+
rpId: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.optional(),
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Self-signup configuration
|
|
54
|
+
*/
|
|
55
|
+
selfSignup: z
|
|
56
|
+
.object({
|
|
57
|
+
/**
|
|
58
|
+
* Email domains allowed to self-register
|
|
59
|
+
*/
|
|
60
|
+
domains: z.array(z.string()),
|
|
61
|
+
/**
|
|
62
|
+
* Default role for self-registered users
|
|
63
|
+
*/
|
|
64
|
+
defaultRole: z.enum(["subscriber", "contributor", "author"] as const).default("contributor"),
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* OAuth provider configurations (for "Login with X")
|
|
70
|
+
*/
|
|
71
|
+
oauth: z
|
|
72
|
+
.object({
|
|
73
|
+
github: oauthProviderSchema.optional(),
|
|
74
|
+
google: oauthProviderSchema.optional(),
|
|
75
|
+
})
|
|
76
|
+
.optional(),
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Configure EmDash as an OAuth provider
|
|
80
|
+
*/
|
|
81
|
+
provider: z
|
|
82
|
+
.object({
|
|
83
|
+
enabled: z.boolean(),
|
|
84
|
+
/**
|
|
85
|
+
* Issuer URL for OIDC. Defaults to site URL.
|
|
86
|
+
*/
|
|
87
|
+
issuer: httpUrl.optional(),
|
|
88
|
+
})
|
|
89
|
+
.optional(),
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Enterprise SSO configuration
|
|
93
|
+
*/
|
|
94
|
+
sso: z
|
|
95
|
+
.object({
|
|
96
|
+
enabled: z.boolean(),
|
|
97
|
+
})
|
|
98
|
+
.optional(),
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Session configuration
|
|
102
|
+
*/
|
|
103
|
+
session: z
|
|
104
|
+
.object({
|
|
105
|
+
/**
|
|
106
|
+
* Session max age in seconds. Default: 30 days
|
|
107
|
+
*/
|
|
108
|
+
maxAge: z.number().default(30 * 24 * 60 * 60),
|
|
109
|
+
/**
|
|
110
|
+
* Extend session on activity. Default: true
|
|
111
|
+
*/
|
|
112
|
+
sliding: z.boolean().default(true),
|
|
113
|
+
})
|
|
114
|
+
.optional(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export type AuthConfig = z.infer<typeof authConfigSchema>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validated and resolved auth configuration
|
|
121
|
+
*/
|
|
122
|
+
export interface ResolvedAuthConfig {
|
|
123
|
+
secret: string;
|
|
124
|
+
baseUrl: string;
|
|
125
|
+
siteName: string;
|
|
126
|
+
|
|
127
|
+
passkeys: {
|
|
128
|
+
rpName: string;
|
|
129
|
+
rpId: string;
|
|
130
|
+
origin: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
selfSignup?: {
|
|
134
|
+
domains: string[];
|
|
135
|
+
defaultRole: RoleName;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
oauth?: {
|
|
139
|
+
github?: {
|
|
140
|
+
clientId: string;
|
|
141
|
+
clientSecret: string;
|
|
142
|
+
};
|
|
143
|
+
google?: {
|
|
144
|
+
clientId: string;
|
|
145
|
+
clientSecret: string;
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
provider?: {
|
|
150
|
+
enabled: boolean;
|
|
151
|
+
issuer: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
sso?: {
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
session: {
|
|
159
|
+
maxAge: number;
|
|
160
|
+
sliding: boolean;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const selfSignupRoleMap: Record<"subscriber" | "contributor" | "author", RoleName> = {
|
|
165
|
+
subscriber: "SUBSCRIBER",
|
|
166
|
+
contributor: "CONTRIBUTOR",
|
|
167
|
+
author: "AUTHOR",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve auth configuration with defaults
|
|
172
|
+
*/
|
|
173
|
+
export function resolveConfig(
|
|
174
|
+
config: AuthConfig,
|
|
175
|
+
baseUrl: string,
|
|
176
|
+
siteName: string,
|
|
177
|
+
): ResolvedAuthConfig {
|
|
178
|
+
const url = new URL(baseUrl);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
secret: config.secret,
|
|
182
|
+
baseUrl,
|
|
183
|
+
siteName,
|
|
184
|
+
|
|
185
|
+
passkeys: {
|
|
186
|
+
rpName: config.passkeys?.rpName ?? siteName,
|
|
187
|
+
rpId: config.passkeys?.rpId ?? url.hostname,
|
|
188
|
+
origin: url.origin,
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
selfSignup: config.selfSignup
|
|
192
|
+
? {
|
|
193
|
+
domains: config.selfSignup.domains.map((d) => d.toLowerCase()),
|
|
194
|
+
defaultRole: selfSignupRoleMap[config.selfSignup.defaultRole],
|
|
195
|
+
}
|
|
196
|
+
: undefined,
|
|
197
|
+
|
|
198
|
+
oauth: config.oauth,
|
|
199
|
+
|
|
200
|
+
provider: config.provider
|
|
201
|
+
? {
|
|
202
|
+
enabled: config.provider.enabled,
|
|
203
|
+
issuer: config.provider.issuer ?? baseUrl,
|
|
204
|
+
}
|
|
205
|
+
: undefined,
|
|
206
|
+
|
|
207
|
+
sso: config.sso,
|
|
208
|
+
|
|
209
|
+
session: {
|
|
210
|
+
maxAge: config.session?.maxAge ?? 30 * 24 * 60 * 60,
|
|
211
|
+
sliding: config.session?.sliding ?? true,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @emdash-cms/auth - Passkey-first authentication for EmDash
|
|
3
|
+
*
|
|
4
|
+
* Email is now handled by the plugin email pipeline (see PLUGIN-EMAIL.md).
|
|
5
|
+
* Auth functions accept an optional `email` send function instead of a
|
|
6
|
+
* hardcoded adapter. The route layer bridges `emdash.email.send()` from
|
|
7
|
+
* the pipeline into the auth functions.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { auth } from '@emdash-cms/auth'
|
|
12
|
+
*
|
|
13
|
+
* export default defineConfig({
|
|
14
|
+
* integrations: [
|
|
15
|
+
* emdash({
|
|
16
|
+
* auth: auth({
|
|
17
|
+
* secret: import.meta.env.EMDASH_AUTH_SECRET,
|
|
18
|
+
* passkeys: { rpName: 'My Site' },
|
|
19
|
+
* }),
|
|
20
|
+
* }),
|
|
21
|
+
* ],
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Types
|
|
27
|
+
export * from "./types.js";
|
|
28
|
+
|
|
29
|
+
// Config
|
|
30
|
+
import { authConfigSchema as _authConfigSchema } from "./config.js";
|
|
31
|
+
export {
|
|
32
|
+
authConfigSchema,
|
|
33
|
+
resolveConfig,
|
|
34
|
+
type AuthConfig,
|
|
35
|
+
type ResolvedAuthConfig,
|
|
36
|
+
} from "./config.js";
|
|
37
|
+
|
|
38
|
+
// RBAC
|
|
39
|
+
export {
|
|
40
|
+
Permissions,
|
|
41
|
+
hasPermission,
|
|
42
|
+
requirePermission,
|
|
43
|
+
canActOnOwn,
|
|
44
|
+
requirePermissionOnResource,
|
|
45
|
+
PermissionError,
|
|
46
|
+
scopesForRole,
|
|
47
|
+
clampScopes,
|
|
48
|
+
type Permission,
|
|
49
|
+
} from "./rbac.js";
|
|
50
|
+
|
|
51
|
+
// Tokens
|
|
52
|
+
export {
|
|
53
|
+
generateToken,
|
|
54
|
+
hashToken,
|
|
55
|
+
generateTokenWithHash,
|
|
56
|
+
generateSessionId,
|
|
57
|
+
generateAuthSecret,
|
|
58
|
+
secureCompare,
|
|
59
|
+
encrypt,
|
|
60
|
+
decrypt,
|
|
61
|
+
// Prefixed API tokens (ec_pat_, ec_oat_, ec_ort_)
|
|
62
|
+
TOKEN_PREFIXES,
|
|
63
|
+
generatePrefixedToken,
|
|
64
|
+
hashPrefixedToken,
|
|
65
|
+
// Scopes
|
|
66
|
+
VALID_SCOPES,
|
|
67
|
+
validateScopes,
|
|
68
|
+
hasScope,
|
|
69
|
+
type ApiTokenScope,
|
|
70
|
+
// PKCE
|
|
71
|
+
computeS256Challenge,
|
|
72
|
+
} from "./tokens.js";
|
|
73
|
+
|
|
74
|
+
// Passkey
|
|
75
|
+
export * from "./passkey/index.js";
|
|
76
|
+
|
|
77
|
+
// Magic Link
|
|
78
|
+
export {
|
|
79
|
+
sendMagicLink,
|
|
80
|
+
verifyMagicLink,
|
|
81
|
+
MagicLinkError,
|
|
82
|
+
type MagicLinkConfig,
|
|
83
|
+
} from "./magic-link/index.js";
|
|
84
|
+
|
|
85
|
+
// Invite
|
|
86
|
+
export {
|
|
87
|
+
createInvite,
|
|
88
|
+
createInviteToken,
|
|
89
|
+
validateInvite,
|
|
90
|
+
completeInvite,
|
|
91
|
+
InviteError,
|
|
92
|
+
escapeHtml,
|
|
93
|
+
type InviteConfig,
|
|
94
|
+
type InviteTokenResult,
|
|
95
|
+
type EmailSendFn,
|
|
96
|
+
} from "./invite.js";
|
|
97
|
+
|
|
98
|
+
// Signup
|
|
99
|
+
export {
|
|
100
|
+
canSignup,
|
|
101
|
+
requestSignup,
|
|
102
|
+
validateSignupToken,
|
|
103
|
+
completeSignup,
|
|
104
|
+
SignupError,
|
|
105
|
+
type SignupConfig,
|
|
106
|
+
} from "./signup.js";
|
|
107
|
+
|
|
108
|
+
// OAuth
|
|
109
|
+
export {
|
|
110
|
+
createAuthorizationUrl,
|
|
111
|
+
handleOAuthCallback,
|
|
112
|
+
OAuthError,
|
|
113
|
+
github,
|
|
114
|
+
google,
|
|
115
|
+
type StateStore,
|
|
116
|
+
type OAuthConsumerConfig,
|
|
117
|
+
} from "./oauth/consumer.js";
|
|
118
|
+
export type { OAuthProvider, OAuthConfig, OAuthProfile, OAuthState } from "./oauth/types.js";
|
|
119
|
+
|
|
120
|
+
// Email types (implementations moved to plugin email pipeline)
|
|
121
|
+
export type { EmailAdapter, EmailMessage } from "./types.js";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create an auth configuration
|
|
125
|
+
*
|
|
126
|
+
* This is a helper function that validates the config at runtime.
|
|
127
|
+
*/
|
|
128
|
+
export function auth(config: import("./config.js").AuthConfig): import("./config.js").AuthConfig {
|
|
129
|
+
// Validate config
|
|
130
|
+
const result = _authConfigSchema.safeParse(config);
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
throw new Error(`Invalid auth config: ${result.error.message}`);
|
|
133
|
+
}
|
|
134
|
+
return result.data;
|
|
135
|
+
}
|
package/src/invite.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invite system for new users
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateTokenWithHash, hashToken } from "./tokens.js";
|
|
6
|
+
import type { AuthAdapter, RoleLevel, EmailMessage, User } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Escape HTML special characters to prevent injection in email templates */
|
|
9
|
+
export function escapeHtml(s: string): string {
|
|
10
|
+
return s
|
|
11
|
+
.replaceAll("&", "&")
|
|
12
|
+
.replaceAll("<", "<")
|
|
13
|
+
.replaceAll(">", ">")
|
|
14
|
+
.replaceAll('"', """);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
18
|
+
|
|
19
|
+
/** Function that sends an email (matches the EmailPipeline.send signature) */
|
|
20
|
+
export type EmailSendFn = (message: EmailMessage) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
export interface InviteConfig {
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
siteName: string;
|
|
25
|
+
/** Optional email sender. When omitted, invite URL is returned without sending. */
|
|
26
|
+
email?: EmailSendFn;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result of creating an invite token (without sending email) */
|
|
30
|
+
export interface InviteTokenResult {
|
|
31
|
+
/** The complete invite URL */
|
|
32
|
+
url: string;
|
|
33
|
+
/** The invite email address */
|
|
34
|
+
email: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create an invite token and URL without sending email.
|
|
39
|
+
*
|
|
40
|
+
* Validates the user doesn't already exist, generates a token, stores it,
|
|
41
|
+
* and returns the invite URL. Callers decide whether to send email or
|
|
42
|
+
* display the URL as a copy-link fallback.
|
|
43
|
+
*/
|
|
44
|
+
export async function createInviteToken(
|
|
45
|
+
config: Pick<InviteConfig, "baseUrl">,
|
|
46
|
+
adapter: AuthAdapter,
|
|
47
|
+
email: string,
|
|
48
|
+
role: RoleLevel,
|
|
49
|
+
invitedBy: string,
|
|
50
|
+
): Promise<InviteTokenResult> {
|
|
51
|
+
// Check if user already exists
|
|
52
|
+
const existing = await adapter.getUserByEmail(email);
|
|
53
|
+
if (existing) {
|
|
54
|
+
throw new InviteError("user_exists", "A user with this email already exists");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Generate token
|
|
58
|
+
const { token, hash } = generateTokenWithHash();
|
|
59
|
+
|
|
60
|
+
// Store token
|
|
61
|
+
await adapter.createToken({
|
|
62
|
+
hash,
|
|
63
|
+
email,
|
|
64
|
+
type: "invite",
|
|
65
|
+
role,
|
|
66
|
+
invitedBy,
|
|
67
|
+
expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Build invite URL
|
|
71
|
+
const url = new URL("/api/auth/invite/accept", config.baseUrl);
|
|
72
|
+
url.searchParams.set("token", token);
|
|
73
|
+
|
|
74
|
+
return { url: url.toString(), email };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the invite email message.
|
|
79
|
+
*/
|
|
80
|
+
function buildInviteEmail(inviteUrl: string, email: string, siteName: string): EmailMessage {
|
|
81
|
+
const safeName = escapeHtml(siteName);
|
|
82
|
+
return {
|
|
83
|
+
to: email,
|
|
84
|
+
subject: `You've been invited to ${siteName}`,
|
|
85
|
+
text: `You've been invited to join ${siteName}.\n\nClick this link to create your account:\n${inviteUrl}\n\nThis link expires in 7 days.`,
|
|
86
|
+
html: `
|
|
87
|
+
<!DOCTYPE html>
|
|
88
|
+
<html>
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="utf-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
92
|
+
</head>
|
|
93
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
94
|
+
<h1 style="font-size: 24px; margin-bottom: 20px;">You've been invited to ${safeName}</h1>
|
|
95
|
+
<p>Click the button below to create your account:</p>
|
|
96
|
+
<p style="margin: 30px 0;">
|
|
97
|
+
<a href="${inviteUrl}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Accept Invite</a>
|
|
98
|
+
</p>
|
|
99
|
+
<p style="color: #666; font-size: 14px;">This link expires in 7 days.</p>
|
|
100
|
+
</body>
|
|
101
|
+
</html>`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create and send an invite to a new user.
|
|
107
|
+
*
|
|
108
|
+
* When `config.email` is provided, sends the invite email.
|
|
109
|
+
* When omitted, creates the token and returns the invite URL
|
|
110
|
+
* without sending (for the copy-link fallback).
|
|
111
|
+
*/
|
|
112
|
+
export async function createInvite(
|
|
113
|
+
config: InviteConfig,
|
|
114
|
+
adapter: AuthAdapter,
|
|
115
|
+
email: string,
|
|
116
|
+
role: RoleLevel,
|
|
117
|
+
invitedBy: string,
|
|
118
|
+
): Promise<InviteTokenResult> {
|
|
119
|
+
const result = await createInviteToken(config, adapter, email, role, invitedBy);
|
|
120
|
+
|
|
121
|
+
// Send email if a sender is configured
|
|
122
|
+
if (config.email) {
|
|
123
|
+
const message = buildInviteEmail(result.url, email, config.siteName);
|
|
124
|
+
await config.email(message);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate an invite token and return the invite data
|
|
132
|
+
*/
|
|
133
|
+
export async function validateInvite(
|
|
134
|
+
adapter: AuthAdapter,
|
|
135
|
+
token: string,
|
|
136
|
+
): Promise<{ email: string; role: RoleLevel }> {
|
|
137
|
+
const hash = hashToken(token);
|
|
138
|
+
|
|
139
|
+
const authToken = await adapter.getToken(hash, "invite");
|
|
140
|
+
if (!authToken) {
|
|
141
|
+
throw new InviteError("invalid_token", "Invalid or expired invite link");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (authToken.expiresAt < new Date()) {
|
|
145
|
+
await adapter.deleteToken(hash);
|
|
146
|
+
throw new InviteError("token_expired", "This invite has expired");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!authToken.email || authToken.role === null) {
|
|
150
|
+
throw new InviteError("invalid_token", "Invalid invite data");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
email: authToken.email,
|
|
155
|
+
role: authToken.role,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Complete the invite process (after passkey registration)
|
|
161
|
+
*/
|
|
162
|
+
export async function completeInvite(
|
|
163
|
+
adapter: AuthAdapter,
|
|
164
|
+
token: string,
|
|
165
|
+
userData: {
|
|
166
|
+
name?: string;
|
|
167
|
+
avatarUrl?: string;
|
|
168
|
+
},
|
|
169
|
+
): Promise<User> {
|
|
170
|
+
const hash = hashToken(token);
|
|
171
|
+
|
|
172
|
+
// Validate token one more time
|
|
173
|
+
const authToken = await adapter.getToken(hash, "invite");
|
|
174
|
+
if (!authToken || authToken.expiresAt < new Date()) {
|
|
175
|
+
throw new InviteError("invalid_token", "Invalid or expired invite");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!authToken.email || authToken.role === null) {
|
|
179
|
+
throw new InviteError("invalid_token", "Invalid invite data");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Delete token (single-use)
|
|
183
|
+
await adapter.deleteToken(hash);
|
|
184
|
+
|
|
185
|
+
// Create user
|
|
186
|
+
const user = await adapter.createUser({
|
|
187
|
+
email: authToken.email,
|
|
188
|
+
name: userData.name,
|
|
189
|
+
avatarUrl: userData.avatarUrl,
|
|
190
|
+
role: authToken.role,
|
|
191
|
+
emailVerified: true, // Email verified by accepting invite
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return user;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export class InviteError extends Error {
|
|
198
|
+
constructor(
|
|
199
|
+
public code: "invalid_token" | "token_expired" | "user_exists",
|
|
200
|
+
message: string,
|
|
201
|
+
) {
|
|
202
|
+
super(message);
|
|
203
|
+
this.name = "InviteError";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic link authentication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { escapeHtml } from "../invite.js";
|
|
6
|
+
import { generateTokenWithHash, hashToken } from "../tokens.js";
|
|
7
|
+
import type { AuthAdapter, User, EmailMessage } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
|
|
10
|
+
|
|
11
|
+
/** Function that sends an email (matches the EmailPipeline.send signature) */
|
|
12
|
+
export type EmailSendFn = (message: EmailMessage) => Promise<void>;
|
|
13
|
+
|
|
14
|
+
export interface MagicLinkConfig {
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
siteName: string;
|
|
17
|
+
/** Optional email sender. When omitted, magic links cannot be sent. */
|
|
18
|
+
email?: EmailSendFn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Add artificial delay with jitter to prevent timing attacks.
|
|
23
|
+
* Range approximates the time for token creation + email send.
|
|
24
|
+
*/
|
|
25
|
+
async function timingDelay(): Promise<void> {
|
|
26
|
+
const delay = 100 + Math.random() * 150; // 100-250ms
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send a magic link to a user's email.
|
|
32
|
+
*
|
|
33
|
+
* Requires `config.email` to be set. Throws if no email sender is configured.
|
|
34
|
+
*/
|
|
35
|
+
export async function sendMagicLink(
|
|
36
|
+
config: MagicLinkConfig,
|
|
37
|
+
adapter: AuthAdapter,
|
|
38
|
+
email: string,
|
|
39
|
+
type: "magic_link" | "recovery" = "magic_link",
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
if (!config.email) {
|
|
42
|
+
throw new MagicLinkError("email_not_configured", "Email is not configured");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Find user
|
|
46
|
+
const user = await adapter.getUserByEmail(email);
|
|
47
|
+
if (!user) {
|
|
48
|
+
// Don't reveal whether user exists - add delay to match successful path timing
|
|
49
|
+
await timingDelay();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Generate token
|
|
54
|
+
const { token, hash } = generateTokenWithHash();
|
|
55
|
+
|
|
56
|
+
// Store token hash
|
|
57
|
+
await adapter.createToken({
|
|
58
|
+
hash,
|
|
59
|
+
userId: user.id,
|
|
60
|
+
email: user.email,
|
|
61
|
+
type,
|
|
62
|
+
expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Build magic link URL
|
|
66
|
+
const url = new URL("/api/auth/magic-link/verify", config.baseUrl);
|
|
67
|
+
url.searchParams.set("token", token);
|
|
68
|
+
|
|
69
|
+
// Send email
|
|
70
|
+
const safeName = escapeHtml(config.siteName);
|
|
71
|
+
await config.email({
|
|
72
|
+
to: user.email,
|
|
73
|
+
subject: `Sign in to ${config.siteName}`,
|
|
74
|
+
text: `Click this link to sign in to ${config.siteName}:\n\n${url.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
|
|
75
|
+
html: `
|
|
76
|
+
<!DOCTYPE html>
|
|
77
|
+
<html>
|
|
78
|
+
<head>
|
|
79
|
+
<meta charset="utf-8">
|
|
80
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
81
|
+
</head>
|
|
82
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
83
|
+
<h1 style="font-size: 24px; margin-bottom: 20px;">Sign in to ${safeName}</h1>
|
|
84
|
+
<p>Click the button below to sign in:</p>
|
|
85
|
+
<p style="margin: 30px 0;">
|
|
86
|
+
<a href="${url.toString()}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Sign in</a>
|
|
87
|
+
</p>
|
|
88
|
+
<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>
|
|
89
|
+
<p style="color: #666; font-size: 14px;">If you didn't request this, you can safely ignore this email.</p>
|
|
90
|
+
</body>
|
|
91
|
+
</html>`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verify a magic link token and return the user
|
|
97
|
+
*/
|
|
98
|
+
export async function verifyMagicLink(adapter: AuthAdapter, token: string): Promise<User> {
|
|
99
|
+
const hash = hashToken(token);
|
|
100
|
+
|
|
101
|
+
// Find and validate token
|
|
102
|
+
const authToken = await adapter.getToken(hash, "magic_link");
|
|
103
|
+
if (!authToken) {
|
|
104
|
+
// Also check for recovery tokens
|
|
105
|
+
const recoveryToken = await adapter.getToken(hash, "recovery");
|
|
106
|
+
if (!recoveryToken) {
|
|
107
|
+
throw new MagicLinkError("invalid_token", "Invalid or expired link");
|
|
108
|
+
}
|
|
109
|
+
return verifyTokenAndGetUser(adapter, recoveryToken, hash);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return verifyTokenAndGetUser(adapter, authToken, hash);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function verifyTokenAndGetUser(
|
|
116
|
+
adapter: AuthAdapter,
|
|
117
|
+
authToken: { userId: string | null; expiresAt: Date },
|
|
118
|
+
hash: string,
|
|
119
|
+
): Promise<User> {
|
|
120
|
+
// Check expiry
|
|
121
|
+
if (authToken.expiresAt < new Date()) {
|
|
122
|
+
await adapter.deleteToken(hash);
|
|
123
|
+
throw new MagicLinkError("token_expired", "This link has expired");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Delete token (single-use)
|
|
127
|
+
await adapter.deleteToken(hash);
|
|
128
|
+
|
|
129
|
+
// Get user
|
|
130
|
+
if (!authToken.userId) {
|
|
131
|
+
throw new MagicLinkError("invalid_token", "Invalid token");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const user = await adapter.getUserById(authToken.userId);
|
|
135
|
+
if (!user) {
|
|
136
|
+
throw new MagicLinkError("user_not_found", "User not found");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return user;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class MagicLinkError extends Error {
|
|
143
|
+
constructor(
|
|
144
|
+
public code: "invalid_token" | "token_expired" | "user_not_found" | "email_not_configured",
|
|
145
|
+
message: string,
|
|
146
|
+
) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.name = "MagicLinkError";
|
|
149
|
+
}
|
|
150
|
+
}
|