@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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth consumer - "Login with X" functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { sha256 } from "@oslojs/crypto/sha2";
|
|
6
|
+
import { encodeBase64urlNoPadding } from "@oslojs/encoding";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import type { AuthAdapter, User, RoleLevel } from "../types.js";
|
|
10
|
+
import { github, fetchGitHubEmail } from "./providers/github.js";
|
|
11
|
+
import { google } from "./providers/google.js";
|
|
12
|
+
import type { OAuthProvider, OAuthConfig, OAuthProfile, OAuthState } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export { github, google };
|
|
15
|
+
|
|
16
|
+
export interface OAuthConsumerConfig {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
providers: {
|
|
19
|
+
github?: OAuthConfig;
|
|
20
|
+
google?: OAuthConfig;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Check if self-signup is allowed for this email domain
|
|
24
|
+
*/
|
|
25
|
+
canSelfSignup?: (email: string) => Promise<{ allowed: boolean; role: RoleLevel } | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate an OAuth authorization URL
|
|
30
|
+
*/
|
|
31
|
+
export async function createAuthorizationUrl(
|
|
32
|
+
config: OAuthConsumerConfig,
|
|
33
|
+
providerName: "github" | "google",
|
|
34
|
+
stateStore: StateStore,
|
|
35
|
+
): Promise<{ url: string; state: string }> {
|
|
36
|
+
const providerConfig = config.providers[providerName];
|
|
37
|
+
if (!providerConfig) {
|
|
38
|
+
throw new Error(`OAuth provider ${providerName} not configured`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const provider = getProvider(providerName);
|
|
42
|
+
const state = generateState();
|
|
43
|
+
const redirectUri = `${config.baseUrl}/api/auth/oauth/${providerName}/callback`;
|
|
44
|
+
|
|
45
|
+
// Generate PKCE code verifier for providers that support it
|
|
46
|
+
const codeVerifier = generateCodeVerifier();
|
|
47
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
48
|
+
|
|
49
|
+
// Store state for verification
|
|
50
|
+
await stateStore.set(state, {
|
|
51
|
+
provider: providerName,
|
|
52
|
+
redirectUri,
|
|
53
|
+
codeVerifier,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Build authorization URL
|
|
57
|
+
const url = new URL(provider.authorizeUrl);
|
|
58
|
+
url.searchParams.set("client_id", providerConfig.clientId);
|
|
59
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
60
|
+
url.searchParams.set("response_type", "code");
|
|
61
|
+
url.searchParams.set("scope", provider.scopes.join(" "));
|
|
62
|
+
url.searchParams.set("state", state);
|
|
63
|
+
|
|
64
|
+
// PKCE for all providers (GitHub has supported S256 since 2021)
|
|
65
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
66
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
67
|
+
|
|
68
|
+
return { url: url.toString(), state };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Handle OAuth callback
|
|
73
|
+
*/
|
|
74
|
+
export async function handleOAuthCallback(
|
|
75
|
+
config: OAuthConsumerConfig,
|
|
76
|
+
adapter: AuthAdapter,
|
|
77
|
+
providerName: "github" | "google",
|
|
78
|
+
code: string,
|
|
79
|
+
state: string,
|
|
80
|
+
stateStore: StateStore,
|
|
81
|
+
): Promise<User> {
|
|
82
|
+
const providerConfig = config.providers[providerName];
|
|
83
|
+
if (!providerConfig) {
|
|
84
|
+
throw new Error(`OAuth provider ${providerName} not configured`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Verify state
|
|
88
|
+
const storedState = await stateStore.get(state);
|
|
89
|
+
if (!storedState || storedState.provider !== providerName) {
|
|
90
|
+
throw new OAuthError("invalid_state", "Invalid OAuth state");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Delete state (single-use)
|
|
94
|
+
await stateStore.delete(state);
|
|
95
|
+
|
|
96
|
+
const provider = getProvider(providerName);
|
|
97
|
+
|
|
98
|
+
// Exchange code for tokens
|
|
99
|
+
const tokens = await exchangeCode(
|
|
100
|
+
provider,
|
|
101
|
+
providerConfig,
|
|
102
|
+
code,
|
|
103
|
+
storedState.redirectUri,
|
|
104
|
+
storedState.codeVerifier,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Fetch user profile
|
|
108
|
+
const profile = await fetchProfile(provider, tokens.accessToken, providerName);
|
|
109
|
+
|
|
110
|
+
// Find or create user
|
|
111
|
+
return findOrCreateUser(config, adapter, providerName, profile);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Exchange authorization code for tokens
|
|
116
|
+
*/
|
|
117
|
+
async function exchangeCode(
|
|
118
|
+
provider: OAuthProvider,
|
|
119
|
+
config: OAuthConfig,
|
|
120
|
+
code: string,
|
|
121
|
+
redirectUri: string,
|
|
122
|
+
codeVerifier?: string,
|
|
123
|
+
): Promise<{ accessToken: string; idToken?: string }> {
|
|
124
|
+
const body = new URLSearchParams({
|
|
125
|
+
grant_type: "authorization_code",
|
|
126
|
+
code,
|
|
127
|
+
redirect_uri: redirectUri,
|
|
128
|
+
client_id: config.clientId,
|
|
129
|
+
client_secret: config.clientSecret,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (codeVerifier) {
|
|
133
|
+
body.set("code_verifier", codeVerifier);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const response = await fetch(provider.tokenUrl, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: {
|
|
139
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
140
|
+
Accept: "application/json",
|
|
141
|
+
},
|
|
142
|
+
body,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const error = await response.text();
|
|
147
|
+
throw new OAuthError("token_exchange_failed", `Token exchange failed: ${error}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const json: unknown = await response.json();
|
|
151
|
+
const data = z
|
|
152
|
+
.object({
|
|
153
|
+
access_token: z.string(),
|
|
154
|
+
id_token: z.string().optional(),
|
|
155
|
+
})
|
|
156
|
+
.parse(json);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
accessToken: data.access_token,
|
|
160
|
+
idToken: data.id_token,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fetch user profile from OAuth provider
|
|
166
|
+
*/
|
|
167
|
+
async function fetchProfile(
|
|
168
|
+
provider: OAuthProvider,
|
|
169
|
+
accessToken: string,
|
|
170
|
+
providerName: string,
|
|
171
|
+
): Promise<OAuthProfile> {
|
|
172
|
+
if (!provider.userInfoUrl) {
|
|
173
|
+
throw new Error("Provider does not have userinfo URL");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const response = await fetch(provider.userInfoUrl, {
|
|
177
|
+
headers: {
|
|
178
|
+
Authorization: `Bearer ${accessToken}`,
|
|
179
|
+
Accept: "application/json",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new OAuthError("profile_fetch_failed", `Failed to fetch profile: ${response.status}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const data = await response.json();
|
|
188
|
+
const profile = provider.parseProfile(data);
|
|
189
|
+
|
|
190
|
+
// GitHub may not return email in main profile
|
|
191
|
+
if (providerName === "github" && !profile.email) {
|
|
192
|
+
profile.email = await fetchGitHubEmail(accessToken);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return profile;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Find existing user or create new one (with auto-linking)
|
|
200
|
+
*/
|
|
201
|
+
async function findOrCreateUser(
|
|
202
|
+
config: OAuthConsumerConfig,
|
|
203
|
+
adapter: AuthAdapter,
|
|
204
|
+
providerName: string,
|
|
205
|
+
profile: OAuthProfile,
|
|
206
|
+
): Promise<User> {
|
|
207
|
+
// Check if OAuth account already linked
|
|
208
|
+
const existingAccount = await adapter.getOAuthAccount(providerName, profile.id);
|
|
209
|
+
if (existingAccount) {
|
|
210
|
+
const user = await adapter.getUserById(existingAccount.userId);
|
|
211
|
+
if (!user) {
|
|
212
|
+
throw new OAuthError("user_not_found", "Linked user not found");
|
|
213
|
+
}
|
|
214
|
+
return user;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check if user with this email exists (auto-link)
|
|
218
|
+
// Only auto-link when the provider has verified the email to prevent
|
|
219
|
+
// account takeover via unverified email on a third-party provider
|
|
220
|
+
const existingUser = await adapter.getUserByEmail(profile.email);
|
|
221
|
+
if (existingUser) {
|
|
222
|
+
if (!profile.emailVerified) {
|
|
223
|
+
throw new OAuthError(
|
|
224
|
+
"signup_not_allowed",
|
|
225
|
+
"Cannot link account: email not verified by provider",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
await adapter.createOAuthAccount({
|
|
229
|
+
provider: providerName,
|
|
230
|
+
providerAccountId: profile.id,
|
|
231
|
+
userId: existingUser.id,
|
|
232
|
+
});
|
|
233
|
+
return existingUser;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check if self-signup is allowed
|
|
237
|
+
if (config.canSelfSignup) {
|
|
238
|
+
const signup = await config.canSelfSignup(profile.email);
|
|
239
|
+
if (signup?.allowed) {
|
|
240
|
+
// Create new user
|
|
241
|
+
const user = await adapter.createUser({
|
|
242
|
+
email: profile.email,
|
|
243
|
+
name: profile.name,
|
|
244
|
+
avatarUrl: profile.avatarUrl,
|
|
245
|
+
role: signup.role,
|
|
246
|
+
emailVerified: profile.emailVerified,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Link OAuth account
|
|
250
|
+
await adapter.createOAuthAccount({
|
|
251
|
+
provider: providerName,
|
|
252
|
+
providerAccountId: profile.id,
|
|
253
|
+
userId: user.id,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return user;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new OAuthError("signup_not_allowed", "Self-signup not allowed for this email domain");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getProvider(name: "github" | "google"): OAuthProvider {
|
|
264
|
+
switch (name) {
|
|
265
|
+
case "github":
|
|
266
|
+
return github;
|
|
267
|
+
case "google":
|
|
268
|
+
return google;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// Helpers
|
|
274
|
+
// ============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Generate a random state string for OAuth CSRF protection
|
|
278
|
+
*/
|
|
279
|
+
function generateState(): string {
|
|
280
|
+
const bytes = new Uint8Array(32);
|
|
281
|
+
crypto.getRandomValues(bytes);
|
|
282
|
+
return encodeBase64urlNoPadding(bytes);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function generateCodeVerifier(): string {
|
|
286
|
+
const bytes = new Uint8Array(32);
|
|
287
|
+
crypto.getRandomValues(bytes);
|
|
288
|
+
return encodeBase64urlNoPadding(bytes);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
292
|
+
const bytes = new TextEncoder().encode(verifier);
|
|
293
|
+
const hash = sha256(bytes);
|
|
294
|
+
return encodeBase64urlNoPadding(hash);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// State storage interface
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
export interface StateStore {
|
|
302
|
+
set(state: string, data: OAuthState): Promise<void>;
|
|
303
|
+
get(state: string): Promise<OAuthState | null>;
|
|
304
|
+
delete(state: string): Promise<void>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ============================================================================
|
|
308
|
+
// Errors
|
|
309
|
+
// ============================================================================
|
|
310
|
+
|
|
311
|
+
export class OAuthError extends Error {
|
|
312
|
+
constructor(
|
|
313
|
+
public code:
|
|
314
|
+
| "invalid_state"
|
|
315
|
+
| "token_exchange_failed"
|
|
316
|
+
| "profile_fetch_failed"
|
|
317
|
+
| "user_not_found"
|
|
318
|
+
| "signup_not_allowed",
|
|
319
|
+
message: string,
|
|
320
|
+
) {
|
|
321
|
+
super(message);
|
|
322
|
+
this.name = "OAuthError";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth provider
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import type { OAuthProvider, OAuthProfile } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const gitHubUserSchema = z.object({
|
|
10
|
+
id: z.number(),
|
|
11
|
+
login: z.string(),
|
|
12
|
+
name: z.string().nullable(),
|
|
13
|
+
email: z.string().nullable(),
|
|
14
|
+
avatar_url: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const gitHubEmailSchema = z.object({
|
|
18
|
+
email: z.string(),
|
|
19
|
+
primary: z.boolean(),
|
|
20
|
+
verified: z.boolean(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const github: OAuthProvider = {
|
|
24
|
+
name: "github",
|
|
25
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
26
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
27
|
+
userInfoUrl: "https://api.github.com/user",
|
|
28
|
+
scopes: ["read:user", "user:email"],
|
|
29
|
+
|
|
30
|
+
parseProfile(data: unknown): OAuthProfile {
|
|
31
|
+
const user = gitHubUserSchema.parse(data);
|
|
32
|
+
return {
|
|
33
|
+
id: String(user.id),
|
|
34
|
+
email: user.email || "", // Will be fetched separately if needed
|
|
35
|
+
name: user.name,
|
|
36
|
+
avatarUrl: user.avatar_url,
|
|
37
|
+
emailVerified: true, // GitHub verifies emails
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch the user's primary email from GitHub
|
|
44
|
+
* (needed because email may not be returned in the basic user endpoint)
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchGitHubEmail(accessToken: string): Promise<string> {
|
|
47
|
+
const response = await fetch("https://api.github.com/user/emails", {
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${accessToken}`,
|
|
50
|
+
Accept: "application/vnd.github+json",
|
|
51
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Failed to fetch GitHub emails: ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const json: unknown = await response.json();
|
|
60
|
+
const emails = z.array(gitHubEmailSchema).parse(json);
|
|
61
|
+
const primary = emails.find((e) => e.primary && e.verified);
|
|
62
|
+
|
|
63
|
+
if (!primary) {
|
|
64
|
+
throw new Error("No verified primary email found on GitHub account");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return primary.email;
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth provider (using OIDC)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import type { OAuthProvider, OAuthProfile } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const googleUserSchema = z.object({
|
|
10
|
+
sub: z.string(),
|
|
11
|
+
email: z.string(),
|
|
12
|
+
email_verified: z.boolean(),
|
|
13
|
+
name: z.string(),
|
|
14
|
+
picture: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const google: OAuthProvider = {
|
|
18
|
+
name: "google",
|
|
19
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
20
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
21
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
22
|
+
scopes: ["openid", "email", "profile"],
|
|
23
|
+
|
|
24
|
+
parseProfile(data: unknown): OAuthProfile {
|
|
25
|
+
const user = googleUserSchema.parse(data);
|
|
26
|
+
return {
|
|
27
|
+
id: user.sub,
|
|
28
|
+
email: user.email,
|
|
29
|
+
name: user.name,
|
|
30
|
+
avatarUrl: user.picture,
|
|
31
|
+
emailVerified: user.email_verified,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface OAuthProfile {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string | null;
|
|
9
|
+
avatarUrl: string | null;
|
|
10
|
+
emailVerified: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OAuthProvider {
|
|
14
|
+
name: string;
|
|
15
|
+
authorizeUrl: string;
|
|
16
|
+
tokenUrl: string;
|
|
17
|
+
userInfoUrl?: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse the user profile from the provider's response
|
|
22
|
+
*/
|
|
23
|
+
parseProfile(data: unknown): OAuthProfile;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OAuthConfig {
|
|
27
|
+
clientId: string;
|
|
28
|
+
clientSecret: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OAuthState {
|
|
32
|
+
provider: string;
|
|
33
|
+
redirectUri: string;
|
|
34
|
+
codeVerifier?: string; // For PKCE
|
|
35
|
+
nonce?: string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey authentication (credential assertion)
|
|
3
|
+
*
|
|
4
|
+
* Based on oslo webauthn documentation:
|
|
5
|
+
* https://webauthn.oslojs.dev/examples/authentication
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
verifyECDSASignature,
|
|
10
|
+
p256,
|
|
11
|
+
decodeSEC1PublicKey,
|
|
12
|
+
decodePKIXECDSASignature,
|
|
13
|
+
} from "@oslojs/crypto/ecdsa";
|
|
14
|
+
import { sha256 } from "@oslojs/crypto/sha2";
|
|
15
|
+
import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding";
|
|
16
|
+
import {
|
|
17
|
+
parseAuthenticatorData,
|
|
18
|
+
parseClientDataJSON,
|
|
19
|
+
ClientDataType,
|
|
20
|
+
createAssertionSignatureMessage,
|
|
21
|
+
} from "@oslojs/webauthn";
|
|
22
|
+
|
|
23
|
+
import { generateToken } from "../tokens.js";
|
|
24
|
+
import type { Credential, AuthAdapter, User } from "../types.js";
|
|
25
|
+
import type {
|
|
26
|
+
AuthenticationOptions,
|
|
27
|
+
AuthenticationResponse,
|
|
28
|
+
VerifiedAuthentication,
|
|
29
|
+
ChallengeStore,
|
|
30
|
+
PasskeyConfig,
|
|
31
|
+
} from "./types.js";
|
|
32
|
+
|
|
33
|
+
const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate authentication options for signing in with a passkey
|
|
37
|
+
*/
|
|
38
|
+
export async function generateAuthenticationOptions(
|
|
39
|
+
config: PasskeyConfig,
|
|
40
|
+
credentials: Credential[],
|
|
41
|
+
challengeStore: ChallengeStore,
|
|
42
|
+
): Promise<AuthenticationOptions> {
|
|
43
|
+
const challenge = generateToken();
|
|
44
|
+
|
|
45
|
+
// Store challenge for verification
|
|
46
|
+
await challengeStore.set(challenge, {
|
|
47
|
+
type: "authentication",
|
|
48
|
+
expiresAt: Date.now() + CHALLENGE_TTL,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
challenge,
|
|
53
|
+
rpId: config.rpId,
|
|
54
|
+
timeout: 60000,
|
|
55
|
+
userVerification: "preferred",
|
|
56
|
+
allowCredentials:
|
|
57
|
+
credentials.length > 0
|
|
58
|
+
? credentials.map((cred) => ({
|
|
59
|
+
type: "public-key" as const,
|
|
60
|
+
id: cred.id,
|
|
61
|
+
transports: cred.transports,
|
|
62
|
+
}))
|
|
63
|
+
: undefined, // Empty = allow any discoverable credential
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verify an authentication response
|
|
69
|
+
*/
|
|
70
|
+
export async function verifyAuthenticationResponse(
|
|
71
|
+
config: PasskeyConfig,
|
|
72
|
+
response: AuthenticationResponse,
|
|
73
|
+
credential: Credential,
|
|
74
|
+
challengeStore: ChallengeStore,
|
|
75
|
+
): Promise<VerifiedAuthentication> {
|
|
76
|
+
// Decode the response
|
|
77
|
+
const clientDataJSON = decodeBase64urlIgnorePadding(response.response.clientDataJSON);
|
|
78
|
+
const authenticatorData = decodeBase64urlIgnorePadding(response.response.authenticatorData);
|
|
79
|
+
const signature = decodeBase64urlIgnorePadding(response.response.signature);
|
|
80
|
+
|
|
81
|
+
// Parse client data
|
|
82
|
+
const clientData = parseClientDataJSON(clientDataJSON);
|
|
83
|
+
|
|
84
|
+
// Verify client data type
|
|
85
|
+
if (clientData.type !== ClientDataType.Get) {
|
|
86
|
+
throw new Error("Invalid client data type");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format)
|
|
90
|
+
const challengeString = encodeBase64urlNoPadding(clientData.challenge);
|
|
91
|
+
const challengeData = await challengeStore.get(challengeString);
|
|
92
|
+
if (!challengeData) {
|
|
93
|
+
throw new Error("Challenge not found or expired");
|
|
94
|
+
}
|
|
95
|
+
if (challengeData.type !== "authentication") {
|
|
96
|
+
throw new Error("Invalid challenge type");
|
|
97
|
+
}
|
|
98
|
+
if (challengeData.expiresAt < Date.now()) {
|
|
99
|
+
await challengeStore.delete(challengeString);
|
|
100
|
+
throw new Error("Challenge expired");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Delete challenge (single-use)
|
|
104
|
+
await challengeStore.delete(challengeString);
|
|
105
|
+
|
|
106
|
+
// Verify origin
|
|
107
|
+
if (clientData.origin !== config.origin) {
|
|
108
|
+
throw new Error(`Invalid origin: expected ${config.origin}, got ${clientData.origin}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parse authenticator data
|
|
112
|
+
const authData = parseAuthenticatorData(authenticatorData);
|
|
113
|
+
|
|
114
|
+
// Verify RP ID hash
|
|
115
|
+
if (!authData.verifyRelyingPartyIdHash(config.rpId)) {
|
|
116
|
+
throw new Error("Invalid RP ID hash");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Verify flags
|
|
120
|
+
if (!authData.userPresent) {
|
|
121
|
+
throw new Error("User presence not verified");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify counter (prevent replay attacks)
|
|
125
|
+
if (authData.signatureCounter !== 0 && authData.signatureCounter <= credential.counter) {
|
|
126
|
+
throw new Error("Invalid signature counter - possible cloned authenticator");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Create the message that was signed
|
|
130
|
+
const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON);
|
|
131
|
+
|
|
132
|
+
// Ensure public key is a Uint8Array (may come as Buffer from some DB drivers)
|
|
133
|
+
const publicKeyBytes =
|
|
134
|
+
credential.publicKey instanceof Uint8Array
|
|
135
|
+
? credential.publicKey
|
|
136
|
+
: new Uint8Array(credential.publicKey);
|
|
137
|
+
|
|
138
|
+
// Decode the stored SEC1-encoded public key and verify signature
|
|
139
|
+
// The signature from WebAuthn is DER-encoded (PKIX format)
|
|
140
|
+
const ecdsaPublicKey = decodeSEC1PublicKey(p256, publicKeyBytes);
|
|
141
|
+
const ecdsaSignature = decodePKIXECDSASignature(signature);
|
|
142
|
+
const hash = sha256(signatureMessage);
|
|
143
|
+
const signatureValid = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
|
|
144
|
+
|
|
145
|
+
if (!signatureValid) {
|
|
146
|
+
throw new Error("Invalid signature");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
credentialId: response.id,
|
|
151
|
+
newCounter: authData.signatureCounter,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Authenticate a user with a passkey
|
|
157
|
+
*/
|
|
158
|
+
export async function authenticateWithPasskey(
|
|
159
|
+
config: PasskeyConfig,
|
|
160
|
+
adapter: AuthAdapter,
|
|
161
|
+
response: AuthenticationResponse,
|
|
162
|
+
challengeStore: ChallengeStore,
|
|
163
|
+
): Promise<User> {
|
|
164
|
+
// Find the credential
|
|
165
|
+
const credential = await adapter.getCredentialById(response.id);
|
|
166
|
+
if (!credential) {
|
|
167
|
+
throw new Error("Credential not found");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Verify the response
|
|
171
|
+
const verified = await verifyAuthenticationResponse(config, response, credential, challengeStore);
|
|
172
|
+
|
|
173
|
+
// Update counter
|
|
174
|
+
await adapter.updateCredentialCounter(verified.credentialId, verified.newCounter);
|
|
175
|
+
|
|
176
|
+
// Get the user
|
|
177
|
+
const user = await adapter.getUserById(credential.userId);
|
|
178
|
+
if (!user) {
|
|
179
|
+
throw new Error("User not found");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return user;
|
|
183
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey authentication module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
RegistrationOptions,
|
|
7
|
+
RegistrationResponse,
|
|
8
|
+
VerifiedRegistration,
|
|
9
|
+
AuthenticationOptions,
|
|
10
|
+
AuthenticationResponse,
|
|
11
|
+
VerifiedAuthentication,
|
|
12
|
+
ChallengeStore,
|
|
13
|
+
ChallengeData,
|
|
14
|
+
PasskeyConfig,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
generateRegistrationOptions,
|
|
19
|
+
verifyRegistrationResponse,
|
|
20
|
+
registerPasskey,
|
|
21
|
+
} from "./register.js";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
generateAuthenticationOptions,
|
|
25
|
+
verifyAuthenticationResponse,
|
|
26
|
+
authenticateWithPasskey,
|
|
27
|
+
} from "./authenticate.js";
|