mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-21 18:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
606
packages/auth/src/provider/password.ts
Normal file
606
packages/auth/src/provider/password.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { v1 } from '@standard-schema/spec';
|
||||
|
||||
/**
|
||||
* Configures a provider that supports username and password authentication. This is usually
|
||||
* paired with the `PasswordUI`.
|
||||
*
|
||||
* ```ts
|
||||
* import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||
* import { PasswordProvider } from "@openauthjs/openauth/provider/password"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* password: PasswordProvider(
|
||||
* PasswordUI({
|
||||
* copy: {
|
||||
* error_email_taken: "This email is already taken."
|
||||
* },
|
||||
* sendCode: (email, code) => console.log(email, code)
|
||||
* })
|
||||
* )
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers
|
||||
* that generate the UI for the following.
|
||||
*
|
||||
* ```ts
|
||||
* PasswordProvider({
|
||||
* // ...
|
||||
* login: (req, form, error) => Promise<Response>
|
||||
* register: (req, state, form, error) => Promise<Response>
|
||||
* change: (req, state, form, error) => Promise<Response>
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* This allows you to create your own UI for each of these screens.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import { UnknownStateError } from '../error.js';
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from '../random.js';
|
||||
import { Storage } from '../storage/storage.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface PasswordHasher<T> {
|
||||
hash(password: string): Promise<T>;
|
||||
verify(password: string, compare: T): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PasswordConfig {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
length?: number;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
hasher?: PasswordHasher<any>;
|
||||
/**
|
||||
* The request handler to generate the UI for the login screen.
|
||||
*
|
||||
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||
* ojects.
|
||||
*
|
||||
* In case of an error, this is called again with the `error`.
|
||||
*
|
||||
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||
* in return.
|
||||
*/
|
||||
login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise<Response>;
|
||||
/**
|
||||
* The request handler to generate the UI for the register screen.
|
||||
*
|
||||
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||
* ojects.
|
||||
*
|
||||
* Also passes in the current `state` of the flow and any `error` that occurred.
|
||||
*
|
||||
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||
* in return.
|
||||
*/
|
||||
register: (
|
||||
req: Request,
|
||||
state: PasswordRegisterState,
|
||||
form?: FormData,
|
||||
error?: PasswordRegisterError
|
||||
) => Promise<Response>;
|
||||
/**
|
||||
* The request handler to generate the UI for the change password screen.
|
||||
*
|
||||
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||
* ojects.
|
||||
*
|
||||
* Also passes in the current `state` of the flow and any `error` that occurred.
|
||||
*
|
||||
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||
* in return.
|
||||
*/
|
||||
change: (
|
||||
req: Request,
|
||||
state: PasswordChangeState,
|
||||
form?: FormData,
|
||||
error?: PasswordChangeError
|
||||
) => Promise<Response>;
|
||||
/**
|
||||
* Callback to send the confirmation pin code to the user.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* sendCode: async (email, code) => {
|
||||
* // Send an email with the code
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
sendCode: (email: string, code: string) => Promise<void>;
|
||||
/**
|
||||
* Callback to validate the password on sign up and password reset.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* validatePassword: (password) => {
|
||||
* return password.length < 8 ? "Password must be at least 8 characters" : undefined
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
validatePassword?:
|
||||
| v1.StandardSchema
|
||||
| ((password: string) => Promise<string | undefined> | string | undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The states that can happen on the register screen.
|
||||
*
|
||||
* | State | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `start` | The user is asked to enter their email address and password to start the flow. |
|
||||
* | `code` | The user needs to enter the pin code to verify their email. |
|
||||
*/
|
||||
export type PasswordRegisterState =
|
||||
| {
|
||||
type: 'start';
|
||||
}
|
||||
| {
|
||||
type: 'code';
|
||||
code: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The errors that can happen on the register screen.
|
||||
*
|
||||
* | Error | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `email_taken` | The email is already taken. |
|
||||
* | `invalid_email` | The email is invalid. |
|
||||
* | `invalid_code` | The code is invalid. |
|
||||
* | `invalid_password` | The password is invalid. |
|
||||
* | `password_mismatch` | The passwords do not match. |
|
||||
*/
|
||||
export type PasswordRegisterError =
|
||||
| {
|
||||
type: 'invalid_code';
|
||||
}
|
||||
| {
|
||||
type: 'email_taken';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_email';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_password';
|
||||
}
|
||||
| {
|
||||
type: 'password_mismatch';
|
||||
}
|
||||
| {
|
||||
type: 'validation_error';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The state of the password change flow.
|
||||
*
|
||||
* | State | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `start` | The user is asked to enter their email address to start the flow. |
|
||||
* | `code` | The user needs to enter the pin code to verify their email. |
|
||||
* | `update` | The user is asked to enter their new password and confirm it. |
|
||||
*/
|
||||
export type PasswordChangeState =
|
||||
| {
|
||||
type: 'start';
|
||||
redirect: string;
|
||||
}
|
||||
| {
|
||||
type: 'code';
|
||||
code: string;
|
||||
email: string;
|
||||
redirect: string;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
redirect: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The errors that can happen on the change password screen.
|
||||
*
|
||||
* | Error | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `invalid_email` | The email is invalid. |
|
||||
* | `invalid_code` | The code is invalid. |
|
||||
* | `invalid_password` | The password is invalid. |
|
||||
* | `password_mismatch` | The passwords do not match. |
|
||||
*/
|
||||
export type PasswordChangeError =
|
||||
| {
|
||||
type: 'invalid_email';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_code';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_password';
|
||||
}
|
||||
| {
|
||||
type: 'password_mismatch';
|
||||
}
|
||||
| {
|
||||
type: 'validation_error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The errors that can happen on the login screen.
|
||||
*
|
||||
* | Error | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `invalid_email` | The email is invalid. |
|
||||
* | `invalid_password` | The password is invalid. |
|
||||
*/
|
||||
export type PasswordLoginError =
|
||||
| {
|
||||
type: 'invalid_password';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_email';
|
||||
};
|
||||
|
||||
export function PasswordProvider(config: PasswordConfig): Provider<{ email: string }> {
|
||||
const hasher = config.hasher ?? ScryptHasher();
|
||||
function generate() {
|
||||
return generateUnbiasedDigits(6);
|
||||
}
|
||||
return {
|
||||
type: 'password',
|
||||
init(routes, ctx) {
|
||||
routes.get('/authorize', async (c) => ctx.forward(c, await config.login(c.req.raw)));
|
||||
|
||||
routes.post('/authorize', async (c) => {
|
||||
const fd = await c.req.formData();
|
||||
async function error(err: PasswordLoginError) {
|
||||
return ctx.forward(c, await config.login(c.req.raw, fd, err));
|
||||
}
|
||||
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||
if (!email) return error({ type: 'invalid_email' });
|
||||
const hash = await Storage.get<HashedPassword>(ctx.storage, ['email', email, 'password']);
|
||||
const password = fd.get('password')?.toString();
|
||||
if (!password || !hash || !(await hasher.verify(password, hash)))
|
||||
return error({ type: 'invalid_password' });
|
||||
return ctx.success(
|
||||
c,
|
||||
{
|
||||
email: email
|
||||
},
|
||||
{
|
||||
invalidate: async (subject) => {
|
||||
await Storage.set(ctx.storage, ['email', email, 'subject'], subject);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
routes.get('/register', async (c) => {
|
||||
const state: PasswordRegisterState = {
|
||||
type: 'start'
|
||||
};
|
||||
await ctx.set(c, 'provider', 60 * 60 * 24, state);
|
||||
return ctx.forward(c, await config.register(c.req.raw, state));
|
||||
});
|
||||
|
||||
routes.post('/register', async (c) => {
|
||||
const fd = await c.req.formData();
|
||||
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||
const action = fd.get('action')?.toString();
|
||||
const provider = await ctx.get<PasswordRegisterState>(c, 'provider');
|
||||
|
||||
async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) {
|
||||
await ctx.set<PasswordRegisterState>(c, 'provider', 60 * 60 * 24, next);
|
||||
return ctx.forward(c, await config.register(c.req.raw, next, fd, err));
|
||||
}
|
||||
|
||||
if (action === 'register' && provider.type === 'start') {
|
||||
const password = fd.get('password')?.toString();
|
||||
const repeat = fd.get('repeat')?.toString();
|
||||
if (!email) return transition(provider, { type: 'invalid_email' });
|
||||
if (!password) return transition(provider, { type: 'invalid_password' });
|
||||
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
|
||||
if (config.validatePassword) {
|
||||
let validationError: string | undefined;
|
||||
try {
|
||||
if (typeof config.validatePassword === 'function') {
|
||||
validationError = await config.validatePassword(password);
|
||||
} else {
|
||||
const res = await config.validatePassword['~standard'].validate(password);
|
||||
|
||||
if (res.issues?.length) {
|
||||
throw new Error(res.issues.map((issue) => issue.message).join(', '));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
validationError = error instanceof Error ? error.message : undefined;
|
||||
}
|
||||
if (validationError)
|
||||
return transition(provider, {
|
||||
type: 'validation_error',
|
||||
message: validationError
|
||||
});
|
||||
}
|
||||
const existing = await Storage.get(ctx.storage, ['email', email, 'password']);
|
||||
if (existing) return transition(provider, { type: 'email_taken' });
|
||||
const code = generate();
|
||||
await config.sendCode(email, code);
|
||||
return transition({
|
||||
type: 'code',
|
||||
code,
|
||||
password: await hasher.hash(password),
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'register' && provider.type === 'code') {
|
||||
const code = generate();
|
||||
await config.sendCode(provider.email, code);
|
||||
return transition({
|
||||
type: 'code',
|
||||
code,
|
||||
password: provider.password,
|
||||
email: provider.email
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'verify' && provider.type === 'code') {
|
||||
const code = fd.get('code')?.toString();
|
||||
if (!code || !timingSafeCompare(code, provider.code))
|
||||
return transition(provider, { type: 'invalid_code' });
|
||||
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
|
||||
if (existing) return transition({ type: 'start' }, { type: 'email_taken' });
|
||||
await Storage.set(ctx.storage, ['email', provider.email, 'password'], provider.password);
|
||||
return ctx.success(c, {
|
||||
email: provider.email
|
||||
});
|
||||
}
|
||||
|
||||
return transition({ type: 'start' });
|
||||
});
|
||||
|
||||
routes.get('/change', async (c) => {
|
||||
let redirect = c.req.query('redirect_uri') || getRelativeUrl(c, './authorize');
|
||||
const state: PasswordChangeState = {
|
||||
type: 'start',
|
||||
redirect
|
||||
};
|
||||
await ctx.set(c, 'provider', 60 * 60 * 24, state);
|
||||
return ctx.forward(c, await config.change(c.req.raw, state));
|
||||
});
|
||||
|
||||
routes.post('/change', async (c) => {
|
||||
const fd = await c.req.formData();
|
||||
const action = fd.get('action')?.toString();
|
||||
const provider = await ctx.get<PasswordChangeState>(c, 'provider');
|
||||
if (!provider) throw new UnknownStateError();
|
||||
|
||||
async function transition(next: PasswordChangeState, err?: PasswordChangeError) {
|
||||
await ctx.set<PasswordChangeState>(c, 'provider', 60 * 60 * 24, next);
|
||||
return ctx.forward(c, await config.change(c.req.raw, next, fd, err));
|
||||
}
|
||||
|
||||
if (action === 'code') {
|
||||
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||
if (!email)
|
||||
return transition(
|
||||
{ type: 'start', redirect: provider.redirect },
|
||||
{ type: 'invalid_email' }
|
||||
);
|
||||
const code = generate();
|
||||
await config.sendCode(email, code);
|
||||
|
||||
return transition({
|
||||
type: 'code',
|
||||
code,
|
||||
email,
|
||||
redirect: provider.redirect
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'verify' && provider.type === 'code') {
|
||||
const code = fd.get('code')?.toString();
|
||||
if (!code || !timingSafeCompare(code, provider.code))
|
||||
return transition(provider, { type: 'invalid_code' });
|
||||
return transition({
|
||||
type: 'update',
|
||||
email: provider.email,
|
||||
redirect: provider.redirect
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'update' && provider.type === 'update') {
|
||||
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
|
||||
if (!existing) return c.redirect(provider.redirect, 302);
|
||||
|
||||
const password = fd.get('password')?.toString();
|
||||
const repeat = fd.get('repeat')?.toString();
|
||||
if (!password) return transition(provider, { type: 'invalid_password' });
|
||||
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
|
||||
|
||||
if (config.validatePassword) {
|
||||
let validationError: string | undefined;
|
||||
try {
|
||||
if (typeof config.validatePassword === 'function') {
|
||||
validationError = await config.validatePassword(password);
|
||||
} else {
|
||||
const res = await config.validatePassword['~standard'].validate(password);
|
||||
|
||||
if (res.issues?.length) {
|
||||
throw new Error(res.issues.map((issue) => issue.message).join(', '));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
validationError = error instanceof Error ? error.message : undefined;
|
||||
}
|
||||
if (validationError)
|
||||
return transition(provider, {
|
||||
type: 'validation_error',
|
||||
message: validationError
|
||||
});
|
||||
}
|
||||
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
['email', provider.email, 'password'],
|
||||
await hasher.hash(password)
|
||||
);
|
||||
const subject = await Storage.get<string>(ctx.storage, [
|
||||
'email',
|
||||
provider.email,
|
||||
'subject'
|
||||
]);
|
||||
if (subject) await ctx.invalidate(subject);
|
||||
|
||||
return c.redirect(provider.redirect, 302);
|
||||
}
|
||||
|
||||
return transition({ type: 'start', redirect: provider.redirect });
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
import { TextEncoder } from 'node:util';
|
||||
|
||||
import * as jose from 'jose';
|
||||
|
||||
interface HashedPassword {}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function PBKDF2Hasher(opts?: { iterations?: number }): PasswordHasher<{
|
||||
hash: string;
|
||||
salt: string;
|
||||
iterations: number;
|
||||
}> {
|
||||
const iterations = opts?.iterations ?? 600000;
|
||||
return {
|
||||
async hash(password) {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(password);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, [
|
||||
'deriveBits'
|
||||
]);
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt: salt,
|
||||
iterations
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
||||
const saltBase64 = jose.base64url.encode(salt);
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
iterations
|
||||
};
|
||||
},
|
||||
async verify(password, compare) {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBytes = encoder.encode(password);
|
||||
const salt = jose.base64url.decode(compare.salt);
|
||||
const params = {
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt,
|
||||
iterations: compare.iterations
|
||||
};
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [
|
||||
'deriveBits'
|
||||
]);
|
||||
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256);
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
||||
return hashBase64 === compare.hash;
|
||||
}
|
||||
};
|
||||
}
|
||||
import { timingSafeEqual, randomBytes, scrypt } from 'node:crypto';
|
||||
|
||||
import { getRelativeUrl } from '../util.js';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function ScryptHasher(opts?: { N?: number; r?: number; p?: number }): PasswordHasher<{
|
||||
hash: string;
|
||||
salt: string;
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
}> {
|
||||
const N = opts?.N ?? 16384;
|
||||
const r = opts?.r ?? 8;
|
||||
const p = opts?.p ?? 1;
|
||||
|
||||
return {
|
||||
async hash(password) {
|
||||
const salt = randomBytes(16);
|
||||
const keyLength = 32; // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
|
||||
const hashBase64 = derivedKey.toString('base64');
|
||||
const saltBase64 = salt.toString('base64');
|
||||
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
N,
|
||||
r,
|
||||
p
|
||||
};
|
||||
},
|
||||
|
||||
async verify(password, compare) {
|
||||
const salt = Buffer.from(compare.salt, 'base64');
|
||||
const keyLength = 32; // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(
|
||||
password,
|
||||
salt,
|
||||
keyLength,
|
||||
{ N: compare.N, r: compare.r, p: compare.p },
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derivedKey);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, 'base64'));
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user