@_mustachio/openauth 0.6.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/dist/esm/client.js +186 -0
- package/dist/esm/css.d.js +0 -0
- package/dist/esm/error.js +73 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/issuer.js +558 -0
- package/dist/esm/jwt.js +16 -0
- package/dist/esm/keys.js +113 -0
- package/dist/esm/pkce.js +35 -0
- package/dist/esm/provider/apple.js +28 -0
- package/dist/esm/provider/arctic.js +43 -0
- package/dist/esm/provider/code.js +58 -0
- package/dist/esm/provider/cognito.js +16 -0
- package/dist/esm/provider/discord.js +15 -0
- package/dist/esm/provider/facebook.js +24 -0
- package/dist/esm/provider/github.js +15 -0
- package/dist/esm/provider/google.js +25 -0
- package/dist/esm/provider/index.js +3 -0
- package/dist/esm/provider/jumpcloud.js +15 -0
- package/dist/esm/provider/keycloak.js +15 -0
- package/dist/esm/provider/linkedin.js +15 -0
- package/dist/esm/provider/m2m.js +17 -0
- package/dist/esm/provider/microsoft.js +24 -0
- package/dist/esm/provider/oauth2.js +119 -0
- package/dist/esm/provider/oidc.js +69 -0
- package/dist/esm/provider/passkey.js +315 -0
- package/dist/esm/provider/password.js +306 -0
- package/dist/esm/provider/provider.js +10 -0
- package/dist/esm/provider/slack.js +15 -0
- package/dist/esm/provider/spotify.js +15 -0
- package/dist/esm/provider/twitch.js +15 -0
- package/dist/esm/provider/x.js +16 -0
- package/dist/esm/provider/yahoo.js +15 -0
- package/dist/esm/random.js +27 -0
- package/dist/esm/storage/aws.js +39 -0
- package/dist/esm/storage/cloudflare.js +42 -0
- package/dist/esm/storage/dynamo.js +116 -0
- package/dist/esm/storage/memory.js +88 -0
- package/dist/esm/storage/storage.js +36 -0
- package/dist/esm/subject.js +7 -0
- package/dist/esm/ui/base.js +407 -0
- package/dist/esm/ui/code.js +151 -0
- package/dist/esm/ui/form.js +43 -0
- package/dist/esm/ui/icon.js +92 -0
- package/dist/esm/ui/passkey.js +329 -0
- package/dist/esm/ui/password.js +338 -0
- package/dist/esm/ui/select.js +187 -0
- package/dist/esm/ui/theme.js +115 -0
- package/dist/esm/util.js +54 -0
- package/dist/types/client.d.ts +466 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/error.d.ts +77 -0
- package/dist/types/error.d.ts.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/issuer.d.ts +465 -0
- package/dist/types/issuer.d.ts.map +1 -0
- package/dist/types/jwt.d.ts +6 -0
- package/dist/types/jwt.d.ts.map +1 -0
- package/dist/types/keys.d.ts +18 -0
- package/dist/types/keys.d.ts.map +1 -0
- package/dist/types/pkce.d.ts +7 -0
- package/dist/types/pkce.d.ts.map +1 -0
- package/dist/types/provider/apple.d.ts +108 -0
- package/dist/types/provider/apple.d.ts.map +1 -0
- package/dist/types/provider/arctic.d.ts +16 -0
- package/dist/types/provider/arctic.d.ts.map +1 -0
- package/dist/types/provider/code.d.ts +74 -0
- package/dist/types/provider/code.d.ts.map +1 -0
- package/dist/types/provider/cognito.d.ts +64 -0
- package/dist/types/provider/cognito.d.ts.map +1 -0
- package/dist/types/provider/discord.d.ts +38 -0
- package/dist/types/provider/discord.d.ts.map +1 -0
- package/dist/types/provider/facebook.d.ts +74 -0
- package/dist/types/provider/facebook.d.ts.map +1 -0
- package/dist/types/provider/github.d.ts +38 -0
- package/dist/types/provider/github.d.ts.map +1 -0
- package/dist/types/provider/google.d.ts +74 -0
- package/dist/types/provider/google.d.ts.map +1 -0
- package/dist/types/provider/index.d.ts +4 -0
- package/dist/types/provider/index.d.ts.map +1 -0
- package/dist/types/provider/jumpcloud.d.ts +38 -0
- package/dist/types/provider/jumpcloud.d.ts.map +1 -0
- package/dist/types/provider/keycloak.d.ts +67 -0
- package/dist/types/provider/keycloak.d.ts.map +1 -0
- package/dist/types/provider/linkedin.d.ts +6 -0
- package/dist/types/provider/linkedin.d.ts.map +1 -0
- package/dist/types/provider/m2m.d.ts +34 -0
- package/dist/types/provider/m2m.d.ts.map +1 -0
- package/dist/types/provider/microsoft.d.ts +89 -0
- package/dist/types/provider/microsoft.d.ts.map +1 -0
- package/dist/types/provider/oauth2.d.ts +133 -0
- package/dist/types/provider/oauth2.d.ts.map +1 -0
- package/dist/types/provider/oidc.d.ts +91 -0
- package/dist/types/provider/oidc.d.ts.map +1 -0
- package/dist/types/provider/passkey.d.ts +143 -0
- package/dist/types/provider/passkey.d.ts.map +1 -0
- package/dist/types/provider/password.d.ts +210 -0
- package/dist/types/provider/password.d.ts.map +1 -0
- package/dist/types/provider/provider.d.ts +29 -0
- package/dist/types/provider/provider.d.ts.map +1 -0
- package/dist/types/provider/slack.d.ts +59 -0
- package/dist/types/provider/slack.d.ts.map +1 -0
- package/dist/types/provider/spotify.d.ts +38 -0
- package/dist/types/provider/spotify.d.ts.map +1 -0
- package/dist/types/provider/twitch.d.ts +38 -0
- package/dist/types/provider/twitch.d.ts.map +1 -0
- package/dist/types/provider/x.d.ts +38 -0
- package/dist/types/provider/x.d.ts.map +1 -0
- package/dist/types/provider/yahoo.d.ts +38 -0
- package/dist/types/provider/yahoo.d.ts.map +1 -0
- package/dist/types/random.d.ts +3 -0
- package/dist/types/random.d.ts.map +1 -0
- package/dist/types/storage/aws.d.ts +4 -0
- package/dist/types/storage/aws.d.ts.map +1 -0
- package/dist/types/storage/cloudflare.d.ts +34 -0
- package/dist/types/storage/cloudflare.d.ts.map +1 -0
- package/dist/types/storage/dynamo.d.ts +65 -0
- package/dist/types/storage/dynamo.d.ts.map +1 -0
- package/dist/types/storage/memory.d.ts +49 -0
- package/dist/types/storage/memory.d.ts.map +1 -0
- package/dist/types/storage/storage.d.ts +15 -0
- package/dist/types/storage/storage.d.ts.map +1 -0
- package/dist/types/subject.d.ts +122 -0
- package/dist/types/subject.d.ts.map +1 -0
- package/dist/types/ui/base.d.ts +5 -0
- package/dist/types/ui/base.d.ts.map +1 -0
- package/dist/types/ui/code.d.ts +104 -0
- package/dist/types/ui/code.d.ts.map +1 -0
- package/dist/types/ui/form.d.ts +6 -0
- package/dist/types/ui/form.d.ts.map +1 -0
- package/dist/types/ui/icon.d.ts +6 -0
- package/dist/types/ui/icon.d.ts.map +1 -0
- package/dist/types/ui/passkey.d.ts +5 -0
- package/dist/types/ui/passkey.d.ts.map +1 -0
- package/dist/types/ui/password.d.ts +139 -0
- package/dist/types/ui/password.d.ts.map +1 -0
- package/dist/types/ui/select.d.ts +55 -0
- package/dist/types/ui/select.d.ts.map +1 -0
- package/dist/types/ui/theme.d.ts +207 -0
- package/dist/types/ui/theme.d.ts.map +1 -0
- package/dist/types/util.d.ts +8 -0
- package/dist/types/util.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/client.ts +749 -0
- package/src/css.d.ts +4 -0
- package/src/error.ts +120 -0
- package/src/index.ts +26 -0
- package/src/issuer.ts +1302 -0
- package/src/jwt.ts +17 -0
- package/src/keys.ts +139 -0
- package/src/pkce.ts +40 -0
- package/src/provider/apple.ts +127 -0
- package/src/provider/arctic.ts +66 -0
- package/src/provider/code.ts +227 -0
- package/src/provider/cognito.ts +74 -0
- package/src/provider/discord.ts +45 -0
- package/src/provider/facebook.ts +84 -0
- package/src/provider/github.ts +45 -0
- package/src/provider/google.ts +85 -0
- package/src/provider/index.ts +3 -0
- package/src/provider/jumpcloud.ts +45 -0
- package/src/provider/keycloak.ts +75 -0
- package/src/provider/linkedin.ts +12 -0
- package/src/provider/m2m.ts +56 -0
- package/src/provider/microsoft.ts +100 -0
- package/src/provider/oauth2.ts +297 -0
- package/src/provider/oidc.ts +179 -0
- package/src/provider/passkey.ts +655 -0
- package/src/provider/password.ts +672 -0
- package/src/provider/provider.ts +33 -0
- package/src/provider/slack.ts +67 -0
- package/src/provider/spotify.ts +45 -0
- package/src/provider/twitch.ts +45 -0
- package/src/provider/x.ts +46 -0
- package/src/provider/yahoo.ts +45 -0
- package/src/random.ts +24 -0
- package/src/storage/aws.ts +59 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/dynamo.ts +193 -0
- package/src/storage/memory.ts +135 -0
- package/src/storage/storage.ts +46 -0
- package/src/subject.ts +130 -0
- package/src/ui/base.tsx +118 -0
- package/src/ui/code.tsx +215 -0
- package/src/ui/form.tsx +40 -0
- package/src/ui/icon.tsx +95 -0
- package/src/ui/passkey.tsx +321 -0
- package/src/ui/password.tsx +405 -0
- package/src/ui/select.tsx +221 -0
- package/src/ui/theme.ts +319 -0
- package/src/ui/ui.css +252 -0
- package/src/util.ts +58 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// src/provider/passkey.ts
|
|
2
|
+
import {
|
|
3
|
+
generateRegistrationOptions,
|
|
4
|
+
verifyRegistrationResponse,
|
|
5
|
+
generateAuthenticationOptions,
|
|
6
|
+
verifyAuthenticationResponse
|
|
7
|
+
} from "@simplewebauthn/server";
|
|
8
|
+
import { Storage } from "../storage/storage.js";
|
|
9
|
+
function uint8ArrayToBase64Url(bytes) {
|
|
10
|
+
let str = "";
|
|
11
|
+
for (const charCode of bytes) {
|
|
12
|
+
str += String.fromCharCode(charCode);
|
|
13
|
+
}
|
|
14
|
+
const base64String = btoa(str);
|
|
15
|
+
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
16
|
+
}
|
|
17
|
+
function base64UrlToUint8Array(base64urlString) {
|
|
18
|
+
const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/");
|
|
19
|
+
const padLength = (4 - base64.length % 4) % 4;
|
|
20
|
+
const padded = base64.padEnd(base64.length + padLength, "=");
|
|
21
|
+
const binary = atob(padded);
|
|
22
|
+
const buffer = new ArrayBuffer(binary.length);
|
|
23
|
+
const bytes = new Uint8Array(buffer);
|
|
24
|
+
for (let i = 0;i < binary.length; i++) {
|
|
25
|
+
bytes[i] = binary.charCodeAt(i);
|
|
26
|
+
}
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
var userKey = (userId) => ["passkey", "user", userId];
|
|
30
|
+
var passkeyKey = (userId, credentialId) => [
|
|
31
|
+
"passkey",
|
|
32
|
+
"user",
|
|
33
|
+
userId,
|
|
34
|
+
"credential",
|
|
35
|
+
credentialId,
|
|
36
|
+
"passkey"
|
|
37
|
+
];
|
|
38
|
+
var optionsKey = (userId) => ["passkey", "user", userId, "options"];
|
|
39
|
+
var userPasskeysIndexKey = (userId) => [
|
|
40
|
+
"passkey",
|
|
41
|
+
"user",
|
|
42
|
+
userId,
|
|
43
|
+
"passkeys"
|
|
44
|
+
];
|
|
45
|
+
var DEFAULT_COPY = {
|
|
46
|
+
error_user_not_allowed: "There is already an account with this email. Login to add a passkey."
|
|
47
|
+
};
|
|
48
|
+
function PasskeyProvider(config) {
|
|
49
|
+
const copy = {
|
|
50
|
+
...DEFAULT_COPY,
|
|
51
|
+
...config.copy
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
type: "passkey",
|
|
55
|
+
init(routes, ctx) {
|
|
56
|
+
const {
|
|
57
|
+
rpName,
|
|
58
|
+
authenticatorSelection,
|
|
59
|
+
attestationType = "none",
|
|
60
|
+
timeout = 5 * 60 * 1000
|
|
61
|
+
} = config;
|
|
62
|
+
async function getStoredUserById(userId) {
|
|
63
|
+
return await Storage.get(ctx.storage, userKey(userId));
|
|
64
|
+
}
|
|
65
|
+
async function saveUser(user) {
|
|
66
|
+
await Storage.set(ctx.storage, userKey(user.id), user);
|
|
67
|
+
}
|
|
68
|
+
async function getStoredPasskeyById(userId, credentialID) {
|
|
69
|
+
const storedPasskey = await Storage.get(ctx.storage, passkeyKey(userId, credentialID));
|
|
70
|
+
if (!storedPasskey)
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
...storedPasskey,
|
|
74
|
+
publicKey: base64UrlToUint8Array(storedPasskey.publicKey)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function getStoredUserPasskeys(userId) {
|
|
78
|
+
const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(userId)) || [];
|
|
79
|
+
const passkeys = [];
|
|
80
|
+
for (const id of passkeyIds) {
|
|
81
|
+
const pk = await getStoredPasskeyById(userId, id);
|
|
82
|
+
if (pk)
|
|
83
|
+
passkeys.push(pk);
|
|
84
|
+
}
|
|
85
|
+
return passkeys;
|
|
86
|
+
}
|
|
87
|
+
async function saveNewStoredPasskey(passkeyData) {
|
|
88
|
+
const storablePasskey = {
|
|
89
|
+
...passkeyData,
|
|
90
|
+
publicKey: uint8ArrayToBase64Url(passkeyData.publicKey)
|
|
91
|
+
};
|
|
92
|
+
await Storage.set(ctx.storage, passkeyKey(passkeyData.userId, passkeyData.id), storablePasskey);
|
|
93
|
+
const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(passkeyData.userId)) || [];
|
|
94
|
+
if (!passkeyIds.includes(passkeyData.id)) {
|
|
95
|
+
passkeyIds.push(passkeyData.id);
|
|
96
|
+
await Storage.set(ctx.storage, userPasskeysIndexKey(passkeyData.userId), passkeyIds);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function updateStoredPasskeyCounter(userId, credentialID, newCounter) {
|
|
100
|
+
const passkey = await getStoredPasskeyById(userId, credentialID);
|
|
101
|
+
if (passkey) {
|
|
102
|
+
passkey.counter = newCounter;
|
|
103
|
+
const storablePasskey = {
|
|
104
|
+
...passkey,
|
|
105
|
+
publicKey: uint8ArrayToBase64Url(passkey.publicKey)
|
|
106
|
+
};
|
|
107
|
+
await Storage.set(ctx.storage, passkeyKey(userId, credentialID), storablePasskey);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
routes.get("/authorize", async (c) => {
|
|
111
|
+
return ctx.forward(c, await config.authorize(c.req.raw));
|
|
112
|
+
});
|
|
113
|
+
routes.get("/register", async (c) => {
|
|
114
|
+
return ctx.forward(c, await config.register(c.req.raw));
|
|
115
|
+
});
|
|
116
|
+
routes.get("/register-request", async (c) => {
|
|
117
|
+
const userId = c.req.query("userId");
|
|
118
|
+
const rpID = config.rpID || c.req.query("rpID");
|
|
119
|
+
const otherDevice = c.req.query("otherDevice") === "true";
|
|
120
|
+
if (!userId) {
|
|
121
|
+
return c.json({ error: "User ID for registration is required." }, 400);
|
|
122
|
+
}
|
|
123
|
+
if (!rpID) {
|
|
124
|
+
return c.json({ error: "RP ID for registration is required." }, 400);
|
|
125
|
+
}
|
|
126
|
+
const username = c.req.query("username") || userId;
|
|
127
|
+
let user = await getStoredUserById(userId);
|
|
128
|
+
if (config.userCanRegisterPasskey) {
|
|
129
|
+
const isAllowed = await config.userCanRegisterPasskey(userId, c.req.raw);
|
|
130
|
+
if (!isAllowed) {
|
|
131
|
+
return c.json({
|
|
132
|
+
error: copy.error_user_not_allowed
|
|
133
|
+
}, 403);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!user) {
|
|
137
|
+
user = { id: userId, username };
|
|
138
|
+
await saveUser(user);
|
|
139
|
+
}
|
|
140
|
+
const userPasskeys = await getStoredUserPasskeys(user.id);
|
|
141
|
+
const regOptions = await generateRegistrationOptions({
|
|
142
|
+
rpName,
|
|
143
|
+
rpID,
|
|
144
|
+
userName: user.username,
|
|
145
|
+
attestationType,
|
|
146
|
+
excludeCredentials: userPasskeys.map((pk) => ({
|
|
147
|
+
id: pk.id,
|
|
148
|
+
transports: pk.transports
|
|
149
|
+
})),
|
|
150
|
+
authenticatorSelection: authenticatorSelection ?? {
|
|
151
|
+
residentKey: "preferred",
|
|
152
|
+
userVerification: "preferred",
|
|
153
|
+
authenticatorAttachment: otherDevice ? "cross-platform" : "platform"
|
|
154
|
+
},
|
|
155
|
+
timeout
|
|
156
|
+
});
|
|
157
|
+
await Storage.set(ctx.storage, optionsKey(user.id), regOptions);
|
|
158
|
+
return c.json(regOptions);
|
|
159
|
+
});
|
|
160
|
+
routes.post("/register-verify", async (c) => {
|
|
161
|
+
const body = await c.req.json();
|
|
162
|
+
const { userId } = c.req.query();
|
|
163
|
+
const rpID = config.rpID || c.req.query("rpID");
|
|
164
|
+
const origin = config.origin || c.req.query("origin");
|
|
165
|
+
if (!userId) {
|
|
166
|
+
return c.json({
|
|
167
|
+
verified: false,
|
|
168
|
+
error: "User ID for verification is required."
|
|
169
|
+
}, 400);
|
|
170
|
+
}
|
|
171
|
+
if (!rpID) {
|
|
172
|
+
return c.json({ error: "RP ID for verification is required." }, 400);
|
|
173
|
+
}
|
|
174
|
+
if (!origin) {
|
|
175
|
+
return c.json({ error: "Origin for verification is required." }, 400);
|
|
176
|
+
}
|
|
177
|
+
const user = await getStoredUserById(userId);
|
|
178
|
+
if (!user) {
|
|
179
|
+
return c.json({ verified: false, error: "User not found during verification." }, 404);
|
|
180
|
+
}
|
|
181
|
+
const regOptions = await Storage.get(ctx.storage, optionsKey(user.id));
|
|
182
|
+
if (!regOptions) {
|
|
183
|
+
return c.json({ verified: false, error: "Registration options not found." }, 400);
|
|
184
|
+
}
|
|
185
|
+
const challenge = regOptions.challenge;
|
|
186
|
+
let verification;
|
|
187
|
+
try {
|
|
188
|
+
verification = await verifyRegistrationResponse({
|
|
189
|
+
response: body,
|
|
190
|
+
expectedChallenge: challenge,
|
|
191
|
+
expectedOrigin: origin,
|
|
192
|
+
expectedRPID: rpID,
|
|
193
|
+
requireUserVerification: authenticatorSelection?.userVerification !== "discouraged"
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error("Passkey Registration Verification Error:", error);
|
|
197
|
+
return c.json({ verified: false, error: error.message }, 400);
|
|
198
|
+
}
|
|
199
|
+
const { verified, registrationInfo } = verification;
|
|
200
|
+
if (verified && registrationInfo) {
|
|
201
|
+
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
|
202
|
+
if (credential) {
|
|
203
|
+
const newPasskey = {
|
|
204
|
+
id: credential.id,
|
|
205
|
+
userId: user.id,
|
|
206
|
+
webauthnUserID: regOptions.user.id,
|
|
207
|
+
publicKey: credential.publicKey,
|
|
208
|
+
counter: credential.counter,
|
|
209
|
+
transports: credential.transports,
|
|
210
|
+
deviceType: credentialDeviceType,
|
|
211
|
+
backedUp: credentialBackedUp
|
|
212
|
+
};
|
|
213
|
+
await saveNewStoredPasskey(newPasskey);
|
|
214
|
+
return ctx.success(c, {
|
|
215
|
+
userId: user.id,
|
|
216
|
+
credentialId: newPasskey.id,
|
|
217
|
+
verified: true
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return c.json({ verified: false, error: "Registration verification failed." }, 400);
|
|
222
|
+
});
|
|
223
|
+
routes.get("/authenticate-options", async (c) => {
|
|
224
|
+
const { userId } = c.req.query();
|
|
225
|
+
if (!userId) {
|
|
226
|
+
return c.json({ error: "User ID for authentication is required." }, 400);
|
|
227
|
+
}
|
|
228
|
+
const rpID = config.rpID || c.req.query("rpID");
|
|
229
|
+
if (!rpID) {
|
|
230
|
+
return c.json({ error: "RP ID for authentication is required." }, 400);
|
|
231
|
+
}
|
|
232
|
+
const userForAuth = await getStoredUserById(userId);
|
|
233
|
+
if (!userForAuth) {
|
|
234
|
+
return c.json({ error: "User not found for authentication." }, 404);
|
|
235
|
+
}
|
|
236
|
+
const userPasskeys = await getStoredUserPasskeys(userForAuth.id);
|
|
237
|
+
const allowCredentialsList = userPasskeys.map((pk) => ({
|
|
238
|
+
id: pk.id,
|
|
239
|
+
transports: pk.transports
|
|
240
|
+
}));
|
|
241
|
+
const authOptions = await generateAuthenticationOptions({
|
|
242
|
+
rpID,
|
|
243
|
+
allowCredentials: allowCredentialsList,
|
|
244
|
+
userVerification: authenticatorSelection?.userVerification ?? "preferred",
|
|
245
|
+
timeout
|
|
246
|
+
});
|
|
247
|
+
await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions);
|
|
248
|
+
return c.json(authOptions);
|
|
249
|
+
});
|
|
250
|
+
routes.post("/authenticate-verify", async (c) => {
|
|
251
|
+
const body = await c.req.json();
|
|
252
|
+
const { userId } = c.req.query();
|
|
253
|
+
if (!userId) {
|
|
254
|
+
return c.json({ error: "User ID for authentication is required." }, 400);
|
|
255
|
+
}
|
|
256
|
+
const rpID = config.rpID || c.req.query("rpID");
|
|
257
|
+
if (!rpID) {
|
|
258
|
+
return c.json({ error: "RP ID for authentication is required." }, 400);
|
|
259
|
+
}
|
|
260
|
+
const origin = config.origin || c.req.query("origin");
|
|
261
|
+
if (!origin) {
|
|
262
|
+
return c.json({ error: "Origin for authentication is required." }, 400);
|
|
263
|
+
}
|
|
264
|
+
const user = await getStoredUserById(userId);
|
|
265
|
+
if (!user) {
|
|
266
|
+
return c.json({ verified: false, error: `User ${userId} not found.` }, 404);
|
|
267
|
+
}
|
|
268
|
+
const authOptions = await Storage.get(ctx.storage, optionsKey(user.id));
|
|
269
|
+
if (!authOptions) {
|
|
270
|
+
return c.json({ error: "Authentication options not found." }, 400);
|
|
271
|
+
}
|
|
272
|
+
const passkey = await getStoredPasskeyById(userId, body.id);
|
|
273
|
+
if (!passkey) {
|
|
274
|
+
return c.json({
|
|
275
|
+
verified: false,
|
|
276
|
+
error: `Passkey ${body.id} not found for user ${user.username}.`
|
|
277
|
+
}, 400);
|
|
278
|
+
}
|
|
279
|
+
const { publicKey, counter, transports } = passkey;
|
|
280
|
+
if (!publicKey || typeof counter !== "number" || !transports) {
|
|
281
|
+
return c.json({ error: "Passkey not found for authentication." }, 400);
|
|
282
|
+
}
|
|
283
|
+
const challenge = authOptions.challenge;
|
|
284
|
+
if (!challenge) {
|
|
285
|
+
return c.json({ error: "Authentication challenge not found." }, 400);
|
|
286
|
+
}
|
|
287
|
+
const verification = await verifyAuthenticationResponse({
|
|
288
|
+
response: body,
|
|
289
|
+
expectedChallenge: challenge,
|
|
290
|
+
expectedOrigin: origin || "",
|
|
291
|
+
expectedRPID: rpID,
|
|
292
|
+
credential: {
|
|
293
|
+
id: passkey.id,
|
|
294
|
+
publicKey,
|
|
295
|
+
counter,
|
|
296
|
+
transports
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const { verified, authenticationInfo } = verification;
|
|
300
|
+
if (verified) {
|
|
301
|
+
await updateStoredPasskeyCounter(user.id, passkey.id, authenticationInfo.newCounter);
|
|
302
|
+
return ctx.success(c, {
|
|
303
|
+
userId: user.id,
|
|
304
|
+
credentialId: passkey.id,
|
|
305
|
+
verified: true
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return c.json({ verified: false, error: "Authentication verification failed." }, 400);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
export {
|
|
314
|
+
PasskeyProvider
|
|
315
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/provider/password.ts
|
|
2
|
+
import { UnknownStateError } from "../error.js";
|
|
3
|
+
import { Storage } from "../storage/storage.js";
|
|
4
|
+
import { generateUnbiasedDigits, timingSafeCompare } from "../random.js";
|
|
5
|
+
import * as jose from "jose";
|
|
6
|
+
import { TextEncoder } from "node:util";
|
|
7
|
+
import { timingSafeEqual, randomBytes, scrypt } from "node:crypto";
|
|
8
|
+
import { getRelativeUrl } from "../util.js";
|
|
9
|
+
function PasswordProvider(config) {
|
|
10
|
+
const hasher = config.hasher ?? ScryptHasher();
|
|
11
|
+
function generate() {
|
|
12
|
+
return generateUnbiasedDigits(6);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
type: "password",
|
|
16
|
+
init(routes, ctx) {
|
|
17
|
+
routes.get("/authorize", async (c) => ctx.forward(c, await config.login(c.req.raw)));
|
|
18
|
+
routes.post("/authorize", async (c) => {
|
|
19
|
+
const fd = await c.req.formData();
|
|
20
|
+
async function error(err) {
|
|
21
|
+
return ctx.forward(c, await config.login(c.req.raw, fd, err));
|
|
22
|
+
}
|
|
23
|
+
const email = fd.get("email")?.toString()?.toLowerCase();
|
|
24
|
+
if (!email)
|
|
25
|
+
return error({ type: "invalid_email" });
|
|
26
|
+
const hash = await Storage.get(ctx.storage, [
|
|
27
|
+
"email",
|
|
28
|
+
email,
|
|
29
|
+
"password"
|
|
30
|
+
]);
|
|
31
|
+
const password = fd.get("password")?.toString();
|
|
32
|
+
if (!password || !hash || !await hasher.verify(password, hash))
|
|
33
|
+
return error({ type: "invalid_password" });
|
|
34
|
+
return ctx.success(c, {
|
|
35
|
+
email
|
|
36
|
+
}, {
|
|
37
|
+
invalidate: async (subject) => {
|
|
38
|
+
await Storage.set(ctx.storage, ["email", email, "subject"], subject);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
routes.get("/register", async (c) => {
|
|
43
|
+
const state = {
|
|
44
|
+
type: "start"
|
|
45
|
+
};
|
|
46
|
+
await ctx.set(c, "provider", 60 * 60 * 24, state);
|
|
47
|
+
return ctx.forward(c, await config.register(c.req.raw, state));
|
|
48
|
+
});
|
|
49
|
+
routes.post("/register", async (c) => {
|
|
50
|
+
const fd = await c.req.formData();
|
|
51
|
+
const email = fd.get("email")?.toString()?.toLowerCase();
|
|
52
|
+
const action = fd.get("action")?.toString();
|
|
53
|
+
const provider = await ctx.get(c, "provider");
|
|
54
|
+
async function transition(next, err) {
|
|
55
|
+
await ctx.set(c, "provider", 60 * 60 * 24, next);
|
|
56
|
+
return ctx.forward(c, await config.register(c.req.raw, next, fd, err));
|
|
57
|
+
}
|
|
58
|
+
if (action === "register" && provider.type === "start") {
|
|
59
|
+
const password = fd.get("password")?.toString();
|
|
60
|
+
const repeat = fd.get("repeat")?.toString();
|
|
61
|
+
if (!email)
|
|
62
|
+
return transition(provider, { type: "invalid_email" });
|
|
63
|
+
if (!password)
|
|
64
|
+
return transition(provider, { type: "invalid_password" });
|
|
65
|
+
if (password !== repeat)
|
|
66
|
+
return transition(provider, { type: "password_mismatch" });
|
|
67
|
+
if (config.validatePassword) {
|
|
68
|
+
let validationError;
|
|
69
|
+
try {
|
|
70
|
+
if (typeof config.validatePassword === "function") {
|
|
71
|
+
validationError = await config.validatePassword(password);
|
|
72
|
+
} else {
|
|
73
|
+
const res = await config.validatePassword["~standard"].validate(password);
|
|
74
|
+
if (res.issues?.length) {
|
|
75
|
+
throw new Error(res.issues.map((issue) => issue.message).join(", "));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
validationError = error instanceof Error ? error.message : undefined;
|
|
80
|
+
}
|
|
81
|
+
if (validationError)
|
|
82
|
+
return transition(provider, {
|
|
83
|
+
type: "validation_error",
|
|
84
|
+
message: validationError
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const existing = await Storage.get(ctx.storage, [
|
|
88
|
+
"email",
|
|
89
|
+
email,
|
|
90
|
+
"password"
|
|
91
|
+
]);
|
|
92
|
+
if (existing)
|
|
93
|
+
return transition(provider, { type: "email_taken" });
|
|
94
|
+
const code = generate();
|
|
95
|
+
await config.sendCode(email, code);
|
|
96
|
+
return transition({
|
|
97
|
+
type: "code",
|
|
98
|
+
code,
|
|
99
|
+
password: await hasher.hash(password),
|
|
100
|
+
email
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (action === "register" && provider.type === "code") {
|
|
104
|
+
const code = generate();
|
|
105
|
+
await config.sendCode(provider.email, code);
|
|
106
|
+
return transition({
|
|
107
|
+
type: "code",
|
|
108
|
+
code,
|
|
109
|
+
password: provider.password,
|
|
110
|
+
email: provider.email
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (action === "verify" && provider.type === "code") {
|
|
114
|
+
const code = fd.get("code")?.toString();
|
|
115
|
+
if (!code || !timingSafeCompare(code, provider.code))
|
|
116
|
+
return transition(provider, { type: "invalid_code" });
|
|
117
|
+
const existing = await Storage.get(ctx.storage, [
|
|
118
|
+
"email",
|
|
119
|
+
provider.email,
|
|
120
|
+
"password"
|
|
121
|
+
]);
|
|
122
|
+
if (existing)
|
|
123
|
+
return transition({ type: "start" }, { type: "email_taken" });
|
|
124
|
+
await Storage.set(ctx.storage, ["email", provider.email, "password"], provider.password);
|
|
125
|
+
return ctx.success(c, {
|
|
126
|
+
email: provider.email
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return transition({ type: "start" });
|
|
130
|
+
});
|
|
131
|
+
routes.get("/change", async (c) => {
|
|
132
|
+
let redirect = c.req.query("redirect_uri") || getRelativeUrl(c, "./authorize");
|
|
133
|
+
const state = {
|
|
134
|
+
type: "start",
|
|
135
|
+
redirect
|
|
136
|
+
};
|
|
137
|
+
await ctx.set(c, "provider", 60 * 60 * 24, state);
|
|
138
|
+
return ctx.forward(c, await config.change(c.req.raw, state));
|
|
139
|
+
});
|
|
140
|
+
routes.post("/change", async (c) => {
|
|
141
|
+
const fd = await c.req.formData();
|
|
142
|
+
const action = fd.get("action")?.toString();
|
|
143
|
+
const provider = await ctx.get(c, "provider");
|
|
144
|
+
if (!provider)
|
|
145
|
+
throw new UnknownStateError;
|
|
146
|
+
async function transition(next, err) {
|
|
147
|
+
await ctx.set(c, "provider", 60 * 60 * 24, next);
|
|
148
|
+
return ctx.forward(c, await config.change(c.req.raw, next, fd, err));
|
|
149
|
+
}
|
|
150
|
+
if (action === "code") {
|
|
151
|
+
const email = fd.get("email")?.toString()?.toLowerCase();
|
|
152
|
+
if (!email)
|
|
153
|
+
return transition({ type: "start", redirect: provider.redirect }, { type: "invalid_email" });
|
|
154
|
+
const code = generate();
|
|
155
|
+
await config.sendCode(email, code);
|
|
156
|
+
return transition({
|
|
157
|
+
type: "code",
|
|
158
|
+
code,
|
|
159
|
+
email,
|
|
160
|
+
redirect: provider.redirect
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (action === "verify" && provider.type === "code") {
|
|
164
|
+
const code = fd.get("code")?.toString();
|
|
165
|
+
if (!code || !timingSafeCompare(code, provider.code))
|
|
166
|
+
return transition(provider, { type: "invalid_code" });
|
|
167
|
+
return transition({
|
|
168
|
+
type: "update",
|
|
169
|
+
email: provider.email,
|
|
170
|
+
redirect: provider.redirect
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (action === "update" && provider.type === "update") {
|
|
174
|
+
const existing = await Storage.get(ctx.storage, [
|
|
175
|
+
"email",
|
|
176
|
+
provider.email,
|
|
177
|
+
"password"
|
|
178
|
+
]);
|
|
179
|
+
if (!existing)
|
|
180
|
+
return c.redirect(provider.redirect, 302);
|
|
181
|
+
const password = fd.get("password")?.toString();
|
|
182
|
+
const repeat = fd.get("repeat")?.toString();
|
|
183
|
+
if (!password)
|
|
184
|
+
return transition(provider, { type: "invalid_password" });
|
|
185
|
+
if (password !== repeat)
|
|
186
|
+
return transition(provider, { type: "password_mismatch" });
|
|
187
|
+
if (config.validatePassword) {
|
|
188
|
+
let validationError;
|
|
189
|
+
try {
|
|
190
|
+
if (typeof config.validatePassword === "function") {
|
|
191
|
+
validationError = await config.validatePassword(password);
|
|
192
|
+
} else {
|
|
193
|
+
const res = await config.validatePassword["~standard"].validate(password);
|
|
194
|
+
if (res.issues?.length) {
|
|
195
|
+
throw new Error(res.issues.map((issue) => issue.message).join(", "));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
validationError = error instanceof Error ? error.message : undefined;
|
|
200
|
+
}
|
|
201
|
+
if (validationError)
|
|
202
|
+
return transition(provider, {
|
|
203
|
+
type: "validation_error",
|
|
204
|
+
message: validationError
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
await Storage.set(ctx.storage, ["email", provider.email, "password"], await hasher.hash(password));
|
|
208
|
+
const subject = await Storage.get(ctx.storage, [
|
|
209
|
+
"email",
|
|
210
|
+
provider.email,
|
|
211
|
+
"subject"
|
|
212
|
+
]);
|
|
213
|
+
if (subject)
|
|
214
|
+
await ctx.invalidate(subject);
|
|
215
|
+
return c.redirect(provider.redirect, 302);
|
|
216
|
+
}
|
|
217
|
+
return transition({ type: "start", redirect: provider.redirect });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function PBKDF2Hasher(opts) {
|
|
223
|
+
const iterations = opts?.iterations ?? 600000;
|
|
224
|
+
return {
|
|
225
|
+
async hash(password) {
|
|
226
|
+
const encoder = new TextEncoder;
|
|
227
|
+
const bytes = encoder.encode(password);
|
|
228
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
229
|
+
const keyMaterial = await crypto.subtle.importKey("raw", bytes, "PBKDF2", false, ["deriveBits"]);
|
|
230
|
+
const hash = await crypto.subtle.deriveBits({
|
|
231
|
+
name: "PBKDF2",
|
|
232
|
+
hash: "SHA-256",
|
|
233
|
+
salt,
|
|
234
|
+
iterations
|
|
235
|
+
}, keyMaterial, 256);
|
|
236
|
+
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
|
237
|
+
const saltBase64 = jose.base64url.encode(salt);
|
|
238
|
+
return {
|
|
239
|
+
hash: hashBase64,
|
|
240
|
+
salt: saltBase64,
|
|
241
|
+
iterations
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
async verify(password, compare) {
|
|
245
|
+
const encoder = new TextEncoder;
|
|
246
|
+
const passwordBytes = encoder.encode(password);
|
|
247
|
+
const salt = jose.base64url.decode(compare.salt);
|
|
248
|
+
const params = {
|
|
249
|
+
name: "PBKDF2",
|
|
250
|
+
hash: "SHA-256",
|
|
251
|
+
salt,
|
|
252
|
+
iterations: compare.iterations
|
|
253
|
+
};
|
|
254
|
+
const keyMaterial = await crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveBits"]);
|
|
255
|
+
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256);
|
|
256
|
+
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
|
257
|
+
return hashBase64 === compare.hash;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function ScryptHasher(opts) {
|
|
262
|
+
const N = opts?.N ?? 16384;
|
|
263
|
+
const r = opts?.r ?? 8;
|
|
264
|
+
const p = opts?.p ?? 1;
|
|
265
|
+
return {
|
|
266
|
+
async hash(password) {
|
|
267
|
+
const salt = randomBytes(16);
|
|
268
|
+
const keyLength = 32;
|
|
269
|
+
const derivedKey = await new Promise((resolve, reject) => {
|
|
270
|
+
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey2) => {
|
|
271
|
+
if (err)
|
|
272
|
+
reject(err);
|
|
273
|
+
else
|
|
274
|
+
resolve(derivedKey2);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
const hashBase64 = derivedKey.toString("base64");
|
|
278
|
+
const saltBase64 = salt.toString("base64");
|
|
279
|
+
return {
|
|
280
|
+
hash: hashBase64,
|
|
281
|
+
salt: saltBase64,
|
|
282
|
+
N,
|
|
283
|
+
r,
|
|
284
|
+
p
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
async verify(password, compare) {
|
|
288
|
+
const salt = Buffer.from(compare.salt, "base64");
|
|
289
|
+
const keyLength = 32;
|
|
290
|
+
const derivedKey = await new Promise((resolve, reject) => {
|
|
291
|
+
scrypt(password, salt, keyLength, { N: compare.N, r: compare.r, p: compare.p }, (err, derivedKey2) => {
|
|
292
|
+
if (err)
|
|
293
|
+
reject(err);
|
|
294
|
+
else
|
|
295
|
+
resolve(derivedKey2);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64"));
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
export {
|
|
303
|
+
ScryptHasher,
|
|
304
|
+
PasswordProvider,
|
|
305
|
+
PBKDF2Hasher
|
|
306
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/provider/slack.ts
|
|
2
|
+
import { Oauth2Provider } from "./oauth2.js";
|
|
3
|
+
function SlackProvider(config) {
|
|
4
|
+
return Oauth2Provider({
|
|
5
|
+
...config,
|
|
6
|
+
type: "slack",
|
|
7
|
+
endpoint: {
|
|
8
|
+
authorization: "https://slack.com/openid/connect/authorize",
|
|
9
|
+
token: "https://slack.com/api/openid.connect.token"
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
SlackProvider
|
|
15
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/provider/spotify.ts
|
|
2
|
+
import { Oauth2Provider } from "./oauth2.js";
|
|
3
|
+
function SpotifyProvider(config) {
|
|
4
|
+
return Oauth2Provider({
|
|
5
|
+
...config,
|
|
6
|
+
type: "spotify",
|
|
7
|
+
endpoint: {
|
|
8
|
+
authorization: "https://accounts.spotify.com/authorize",
|
|
9
|
+
token: "https://accounts.spotify.com/api/token"
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
SpotifyProvider
|
|
15
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/provider/twitch.ts
|
|
2
|
+
import { Oauth2Provider } from "./oauth2.js";
|
|
3
|
+
function TwitchProvider(config) {
|
|
4
|
+
return Oauth2Provider({
|
|
5
|
+
type: "twitch",
|
|
6
|
+
...config,
|
|
7
|
+
endpoint: {
|
|
8
|
+
authorization: "https://id.twitch.tv/oauth2/authorize",
|
|
9
|
+
token: "https://id.twitch.tv/oauth2/token"
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
TwitchProvider
|
|
15
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/provider/x.ts
|
|
2
|
+
import { Oauth2Provider } from "./oauth2.js";
|
|
3
|
+
function XProvider(config) {
|
|
4
|
+
return Oauth2Provider({
|
|
5
|
+
...config,
|
|
6
|
+
type: "x",
|
|
7
|
+
endpoint: {
|
|
8
|
+
authorization: "https://twitter.com/i/oauth2/authorize",
|
|
9
|
+
token: "https://api.x.com/2/oauth2/token"
|
|
10
|
+
},
|
|
11
|
+
pkce: true
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
XProvider
|
|
16
|
+
};
|