mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
729
packages/auth/src/client.ts
Normal file
729
packages/auth/src/client.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
import type { v1 } from '@standard-schema/spec';
|
||||
/**
|
||||
* Use the OpenAuth client kick off your OAuth flows, exchange tokens, refresh tokens,
|
||||
* and verify tokens.
|
||||
*
|
||||
* First, create a client.
|
||||
*
|
||||
* ```ts title="client.ts"
|
||||
* import { createClient } from "@openauthjs/openauth/client"
|
||||
*
|
||||
* const client = createClient({
|
||||
* clientID: "my-client",
|
||||
* issuer: "https://auth.myserver.com"
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Kick off the OAuth flow by calling `authorize`.
|
||||
*
|
||||
* ```ts
|
||||
* const redirect_uri = "https://myserver.com/callback"
|
||||
*
|
||||
* const { url } = await client.authorize(
|
||||
* redirect_uri,
|
||||
* "code"
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* When the user completes the flow, `exchange` the code for tokens.
|
||||
*
|
||||
* ```ts
|
||||
* const tokens = await client.exchange(query.get("code"), redirect_uri)
|
||||
* ```
|
||||
*
|
||||
* And `verify` the tokens.
|
||||
*
|
||||
* ```ts
|
||||
* const verified = await client.verify(subjects, tokens.access)
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import { createLocalJWKSet, errors, JSONWebKeySet, jwtVerify, decodeJwt } from 'jose';
|
||||
|
||||
import {
|
||||
InvalidAccessTokenError,
|
||||
InvalidAuthorizationCodeError,
|
||||
InvalidRefreshTokenError,
|
||||
InvalidSubjectError
|
||||
} from './error.js';
|
||||
import { generatePKCE } from './pkce.js';
|
||||
import { SubjectSchema } from './subject.js';
|
||||
|
||||
/**
|
||||
* The well-known information for an OAuth 2.0 authorization server.
|
||||
* @internal
|
||||
*/
|
||||
export interface WellKnown {
|
||||
/**
|
||||
* The URI to the JWKS endpoint.
|
||||
*/
|
||||
jwks_uri: string;
|
||||
/**
|
||||
* The URI to the token endpoint.
|
||||
*/
|
||||
token_endpoint: string;
|
||||
/**
|
||||
* The URI to the authorization endpoint.
|
||||
*/
|
||||
authorization_endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens returned by the auth server.
|
||||
*/
|
||||
export interface Tokens {
|
||||
/**
|
||||
* The access token.
|
||||
*/
|
||||
access: string;
|
||||
/**
|
||||
* The refresh token.
|
||||
*/
|
||||
refresh: string;
|
||||
|
||||
/**
|
||||
* The number of seconds until the access token expires.
|
||||
*/
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface ResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: Response['ok'];
|
||||
}
|
||||
type FetchLike = (...args: any[]) => Promise<ResponseLike>;
|
||||
|
||||
/**
|
||||
* The challenge that you can use to verify the code.
|
||||
*/
|
||||
export type Challenge = {
|
||||
/**
|
||||
* The state that was sent to the redirect URI.
|
||||
*/
|
||||
state: string;
|
||||
/**
|
||||
* The verifier that was sent to the redirect URI.
|
||||
*/
|
||||
verifier?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure the client.
|
||||
*/
|
||||
export interface ClientInput {
|
||||
/**
|
||||
* The client ID. This is just a string to identify your app.
|
||||
*
|
||||
* If you have a web app and a mobile app, you want to use different client IDs both.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* clientID: "my-client"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
clientID: string;
|
||||
/**
|
||||
* The URL of your OpenAuth server.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* issuer: "https://auth.myserver.com"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
issuer?: string;
|
||||
/**
|
||||
* Optionally, override the internally used fetch function.
|
||||
*
|
||||
* This is useful if you are using a polyfilled fetch function in your application and you
|
||||
* want the client to use it too.
|
||||
*/
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
export interface AuthorizeOptions {
|
||||
/**
|
||||
* Enable the PKCE flow. This is for SPA apps.
|
||||
*
|
||||
* ```ts
|
||||
* {
|
||||
* pkce: true
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
pkce?: boolean;
|
||||
/**
|
||||
* The provider you want to use for the OAuth flow.
|
||||
*
|
||||
* ```ts
|
||||
* {
|
||||
* provider: "google"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* If no provider is specified, the user is directed to a page where they can select from the
|
||||
* list of configured providers.
|
||||
*
|
||||
* If there's only one provider configured, the user will be redirected to that.
|
||||
*/
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeResult {
|
||||
/**
|
||||
* The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps.
|
||||
*
|
||||
* This is an object that you _stringify_ and store it in session storage.
|
||||
*
|
||||
* ```ts
|
||||
* sessionStorage.setItem("challenge", JSON.stringify(challenge))
|
||||
* ```
|
||||
*/
|
||||
challenge: Challenge;
|
||||
/**
|
||||
* The URL to redirect the user to. This starts the OAuth flow.
|
||||
*
|
||||
* For example, for SPA apps.
|
||||
*
|
||||
* ```ts
|
||||
* location.href = url
|
||||
* ```
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when the exchange is successful.
|
||||
*/
|
||||
export interface ExchangeSuccess {
|
||||
/**
|
||||
* This is always `false` when the exchange is successful.
|
||||
*/
|
||||
err: false;
|
||||
/**
|
||||
* The access and refresh tokens.
|
||||
*/
|
||||
tokens: Tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when the exchange fails.
|
||||
*/
|
||||
export interface ExchangeError {
|
||||
/**
|
||||
* The type of error that occurred. You can handle this by checking the type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* console.log(err instanceof InvalidAuthorizationCodeError)
|
||||
*```
|
||||
*/
|
||||
err: InvalidAuthorizationCodeError;
|
||||
}
|
||||
|
||||
export interface RefreshOptions {
|
||||
/**
|
||||
* Optionally, pass in the access token.
|
||||
*/
|
||||
access?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when the refresh is successful.
|
||||
*/
|
||||
export interface RefreshSuccess {
|
||||
/**
|
||||
* This is always `false` when the refresh is successful.
|
||||
*/
|
||||
err: false;
|
||||
/**
|
||||
* Returns the refreshed tokens only if they've been refreshed.
|
||||
*
|
||||
* If they are still valid, this will be `undefined`.
|
||||
*/
|
||||
tokens?: Tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when the refresh fails.
|
||||
*/
|
||||
export interface RefreshError {
|
||||
/**
|
||||
* The type of error that occurred. You can handle this by checking the type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* console.log(err instanceof InvalidRefreshTokenError)
|
||||
*```
|
||||
*/
|
||||
err: InvalidRefreshTokenError | InvalidAccessTokenError;
|
||||
}
|
||||
|
||||
export interface VerifyOptions {
|
||||
/**
|
||||
* Optionally, pass in the refresh token.
|
||||
*
|
||||
* If passed in, this will automatically refresh the access token if it has expired.
|
||||
*/
|
||||
refresh?: string;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
issuer?: string;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
audience?: string;
|
||||
/**
|
||||
* Optionally, override the internally used fetch function.
|
||||
*
|
||||
* This is useful if you are using a polyfilled fetch function in your application and you
|
||||
* want the client to use it too.
|
||||
*/
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
export interface VerifyResult<T extends SubjectSchema> {
|
||||
/**
|
||||
* This is always `undefined` when the verify is successful.
|
||||
*/
|
||||
err?: undefined;
|
||||
/**
|
||||
* Returns the refreshed tokens only if they’ve been refreshed.
|
||||
*
|
||||
* If they are still valid, this will be undefined.
|
||||
*/
|
||||
tokens?: Tokens;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
aud: string;
|
||||
/**
|
||||
* The decoded subjects from the access token.
|
||||
*
|
||||
* Has the same shape as the subjects you defined when creating the issuer.
|
||||
*/
|
||||
subject: {
|
||||
[type in keyof T]: { type: type; properties: v1.InferOutput<T[type]> };
|
||||
}[keyof T];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when the verify call fails.
|
||||
*/
|
||||
export interface VerifyError {
|
||||
/**
|
||||
* The type of error that occurred. You can handle this by checking the type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* console.log(err instanceof InvalidRefreshTokenError)
|
||||
*```
|
||||
*/
|
||||
err: InvalidRefreshTokenError | InvalidAccessTokenError;
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance of the OpenAuth client contains the following methods.
|
||||
*/
|
||||
export interface Client {
|
||||
/**
|
||||
* Start the autorization flow. For example, in SSR sites.
|
||||
*
|
||||
* ```ts
|
||||
* const { url } = await client.authorize(<redirect_uri>, "code")
|
||||
* ```
|
||||
*
|
||||
* This takes a redirect URI and the type of flow you want to use. The redirect URI is the
|
||||
* location where the user will be redirected to after the flow is complete.
|
||||
*
|
||||
* Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more
|
||||
* secure.
|
||||
*
|
||||
* :::tip
|
||||
* This returns a URL to redirect the user to. This starts the OAuth flow.
|
||||
* :::
|
||||
*
|
||||
* This returns a URL to the auth server. You can redirect the user to the URL to start the
|
||||
* OAuth flow.
|
||||
*
|
||||
* For SPA apps, we recommend using the PKCE flow.
|
||||
*
|
||||
* ```ts {4}
|
||||
* const { challenge, url } = await client.authorize(
|
||||
* <redirect_uri>,
|
||||
* "code",
|
||||
* { pkce: true }
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* This returns a redirect URL and a challenge that you need to use later to verify the code.
|
||||
*/
|
||||
authorize(
|
||||
redirectURI: string,
|
||||
response: 'code' | 'token',
|
||||
opts?: AuthorizeOptions
|
||||
): Promise<AuthorizeResult>;
|
||||
/**
|
||||
* Exchange the code for access and refresh tokens.
|
||||
*
|
||||
* ```ts
|
||||
* const exchanged = await client.exchange(<code>, <redirect_uri>)
|
||||
* ```
|
||||
*
|
||||
* You call this after the user has been redirected back to your app after the OAuth flow.
|
||||
*
|
||||
* :::tip
|
||||
* For SSR sites, the code is returned in the query parameter.
|
||||
* :::
|
||||
*
|
||||
* So the code comes from the query parameter in the redirect URI. The redirect URI here is
|
||||
* the one that you passed in to the `authorize` call when starting the flow.
|
||||
*
|
||||
* :::tip
|
||||
* For SPA sites, the code is returned through the URL hash.
|
||||
* :::
|
||||
*
|
||||
* If you used the PKCE flow for an SPA app, the code is returned as a part of the redirect URL
|
||||
* hash.
|
||||
*
|
||||
* ```ts {4}
|
||||
* const exchanged = await client.exchange(
|
||||
* <code>,
|
||||
* <redirect_uri>,
|
||||
* <challenge.verifier>
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* You also need to pass in the previously stored challenge verifier.
|
||||
*
|
||||
* This method returns the access and refresh tokens. Or if it fails, it returns an error that
|
||||
* you can handle depending on the error.
|
||||
*
|
||||
* ```ts
|
||||
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* if (exchanged.err) {
|
||||
* if (exchanged.err instanceof InvalidAuthorizationCodeError) {
|
||||
* // handle invalid code error
|
||||
* }
|
||||
* else {
|
||||
* // handle other errors
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const { access, refresh } = exchanged.tokens
|
||||
* ```
|
||||
*/
|
||||
exchange(
|
||||
code: string,
|
||||
redirectURI: string,
|
||||
verifier?: string
|
||||
): Promise<ExchangeSuccess | ExchangeError>;
|
||||
/**
|
||||
* Refreshes the tokens if they have expired. This is used in an SPA app to maintain the
|
||||
* session, without logging the user out.
|
||||
*
|
||||
* ```ts
|
||||
* const next = await client.refresh(<refresh_token>)
|
||||
* ```
|
||||
*
|
||||
* Can optionally take the access token as well. If passed in, this will skip the refresh
|
||||
* if the access token is still valid.
|
||||
*
|
||||
* ```ts
|
||||
* const next = await client.refresh(<refresh_token>, { access: <access_token> })
|
||||
* ```
|
||||
*
|
||||
* This returns the refreshed tokens only if they've been refreshed.
|
||||
*
|
||||
* ```ts
|
||||
* if (!next.err) {
|
||||
* // tokens are still valid
|
||||
* }
|
||||
* if (next.tokens) {
|
||||
* const { access, refresh } = next.tokens
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Or if it fails, it returns an error that you can handle depending on the error.
|
||||
*
|
||||
* ```ts
|
||||
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* if (next.err) {
|
||||
* if (next.err instanceof InvalidRefreshTokenError) {
|
||||
* // handle invalid refresh token error
|
||||
* }
|
||||
* else {
|
||||
* // handle other errors
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
refresh(refresh: string, opts?: RefreshOptions): Promise<RefreshSuccess | RefreshError>;
|
||||
/**
|
||||
* Verify the token in the incoming request.
|
||||
*
|
||||
* This is typically used for SSR sites where the token is stored in an HTTP only cookie. And
|
||||
* is passed to the server on every request.
|
||||
*
|
||||
* ```ts
|
||||
* const verified = await client.verify(<subjects>, <token>)
|
||||
* ```
|
||||
*
|
||||
* This takes the subjects that you had previously defined when creating the issuer.
|
||||
*
|
||||
* :::tip
|
||||
* If the refresh token is passed in, it'll automatically refresh the access token.
|
||||
* :::
|
||||
*
|
||||
* This can optionally take the refresh token as well. If passed in, it'll automatically
|
||||
* refresh the access token if it has expired.
|
||||
*
|
||||
* ```ts
|
||||
* const verified = await client.verify(<subjects>, <token>, { refresh: <refresh_token> })
|
||||
* ```
|
||||
*
|
||||
* This returns the decoded subjects from the access token. And the tokens if they've been
|
||||
* refreshed.
|
||||
*
|
||||
* ```ts
|
||||
* // based on the subjects you defined earlier
|
||||
* console.log(verified.subject.properties.userID)
|
||||
*
|
||||
* if (verified.tokens) {
|
||||
* const { access, refresh } = verified.tokens
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Or if it fails, it returns an error that you can handle depending on the error.
|
||||
*
|
||||
* ```ts
|
||||
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* if (verified.err) {
|
||||
* if (verified.err instanceof InvalidRefreshTokenError) {
|
||||
* // handle invalid refresh token error
|
||||
* }
|
||||
* else {
|
||||
* // handle other errors
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
verify<T extends SubjectSchema>(
|
||||
subjects: T,
|
||||
token: string,
|
||||
options?: VerifyOptions
|
||||
): Promise<VerifyResult<T> | VerifyError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OpenAuth client.
|
||||
*
|
||||
* @param input - Configure the client.
|
||||
*/
|
||||
export function createClient(input: ClientInput): Client {
|
||||
const jwksCache = new Map<string, ReturnType<typeof createLocalJWKSet>>();
|
||||
const issuerCache = new Map<string, WellKnown>();
|
||||
const issuer = input.issuer || process.env.OPENAUTH_ISSUER;
|
||||
if (!issuer) throw new Error('No issuer');
|
||||
const f = input.fetch ?? fetch;
|
||||
|
||||
async function getIssuer() {
|
||||
const cached = issuerCache.get(issuer!);
|
||||
if (cached) return cached;
|
||||
const wellKnown = (await (f || fetch)(`${issuer}/.well-known/oauth-authorization-server`).then(
|
||||
(r) => r.json()
|
||||
)) as WellKnown;
|
||||
issuerCache.set(issuer!, wellKnown);
|
||||
return wellKnown;
|
||||
}
|
||||
|
||||
async function getJWKS() {
|
||||
const wk = await getIssuer();
|
||||
const cached = jwksCache.get(issuer!);
|
||||
if (cached) return cached;
|
||||
const keyset = (await (f || fetch)(wk.jwks_uri).then((r) => r.json())) as JSONWebKeySet;
|
||||
const result = createLocalJWKSet(keyset);
|
||||
jwksCache.set(issuer!, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = {
|
||||
async authorize(redirectURI: string, response: 'code' | 'token', opts?: AuthorizeOptions) {
|
||||
const result = new URL(issuer + '/authorize');
|
||||
const challenge: Challenge = {
|
||||
state: crypto.randomUUID()
|
||||
};
|
||||
result.searchParams.set('client_id', input.clientID);
|
||||
result.searchParams.set('redirect_uri', redirectURI);
|
||||
result.searchParams.set('response_type', response);
|
||||
result.searchParams.set('state', challenge.state);
|
||||
if (opts?.provider) result.searchParams.set('provider', opts.provider);
|
||||
if (opts?.pkce && response === 'code') {
|
||||
const pkce = await generatePKCE();
|
||||
result.searchParams.set('code_challenge_method', 'S256');
|
||||
result.searchParams.set('code_challenge', pkce.challenge);
|
||||
challenge.verifier = pkce.verifier;
|
||||
}
|
||||
return {
|
||||
challenge,
|
||||
url: result.toString()
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @deprecated use `authorize` instead, it will do pkce by default unless disabled with `opts.pkce = false`
|
||||
*/
|
||||
async pkce(
|
||||
redirectURI: string,
|
||||
opts?: {
|
||||
provider?: string;
|
||||
}
|
||||
) {
|
||||
const result = new URL(issuer + '/authorize');
|
||||
if (opts?.provider) result.searchParams.set('provider', opts.provider);
|
||||
result.searchParams.set('client_id', input.clientID);
|
||||
result.searchParams.set('redirect_uri', redirectURI);
|
||||
result.searchParams.set('response_type', 'code');
|
||||
const pkce = await generatePKCE();
|
||||
result.searchParams.set('code_challenge_method', 'S256');
|
||||
result.searchParams.set('code_challenge', pkce.challenge);
|
||||
return [pkce.verifier, result.toString()];
|
||||
},
|
||||
async exchange(
|
||||
code: string,
|
||||
redirectURI: string,
|
||||
verifier?: string
|
||||
): Promise<ExchangeSuccess | ExchangeError> {
|
||||
const tokens = await f(issuer + '/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
grant_type: 'authorization_code',
|
||||
client_id: input.clientID,
|
||||
code_verifier: verifier || ''
|
||||
}).toString()
|
||||
});
|
||||
const json = (await tokens.json()) as any;
|
||||
if (!tokens.ok) {
|
||||
return {
|
||||
err: new InvalidAuthorizationCodeError()
|
||||
};
|
||||
}
|
||||
return {
|
||||
err: false,
|
||||
tokens: {
|
||||
access: json.access_token as string,
|
||||
refresh: json.refresh_token as string,
|
||||
expiresIn: json.expires_in as number
|
||||
}
|
||||
};
|
||||
},
|
||||
async refresh(refresh: string, opts?: RefreshOptions): Promise<RefreshSuccess | RefreshError> {
|
||||
if (opts && opts.access) {
|
||||
const decoded = decodeJwt(opts.access);
|
||||
if (!decoded) {
|
||||
return {
|
||||
err: new InvalidAccessTokenError()
|
||||
};
|
||||
}
|
||||
// allow 30s window for expiration
|
||||
if ((decoded.exp || 0) > Date.now() / 1000 + 30) {
|
||||
return {
|
||||
err: false
|
||||
};
|
||||
}
|
||||
}
|
||||
const tokens = await f(issuer + '/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refresh
|
||||
}).toString()
|
||||
});
|
||||
const json = (await tokens.json()) as any;
|
||||
if (!tokens.ok) {
|
||||
return {
|
||||
err: new InvalidRefreshTokenError()
|
||||
};
|
||||
}
|
||||
return {
|
||||
err: false,
|
||||
tokens: {
|
||||
access: json.access_token as string,
|
||||
refresh: json.refresh_token as string,
|
||||
expiresIn: json.expires_in as number
|
||||
}
|
||||
};
|
||||
},
|
||||
async verify<T extends SubjectSchema>(
|
||||
subjects: T,
|
||||
token: string,
|
||||
options?: VerifyOptions
|
||||
): Promise<VerifyResult<T> | VerifyError> {
|
||||
const jwks = await getJWKS();
|
||||
try {
|
||||
const result = await jwtVerify<{
|
||||
mode: 'access';
|
||||
type: keyof T;
|
||||
properties: v1.InferInput<T[keyof T]>;
|
||||
}>(token, jwks, {
|
||||
issuer
|
||||
});
|
||||
const validated = await subjects[result.payload.type]['~standard'].validate(
|
||||
result.payload.properties
|
||||
);
|
||||
if (!validated.issues && result.payload.mode === 'access') {
|
||||
return {
|
||||
aud: result.payload.aud as string,
|
||||
subject: {
|
||||
type: result.payload.type,
|
||||
properties: validated.value
|
||||
} as any
|
||||
};
|
||||
}
|
||||
return {
|
||||
err: new InvalidSubjectError()
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof errors.JWTExpired && options?.refresh) {
|
||||
const refreshed = await this.refresh(options.refresh);
|
||||
if (refreshed.err) return refreshed;
|
||||
const verified = await result.verify(subjects, refreshed.tokens!.access, {
|
||||
refresh: refreshed.tokens!.refresh,
|
||||
issuer,
|
||||
fetch: options?.fetch
|
||||
});
|
||||
if (verified.err) return verified;
|
||||
verified.tokens = refreshed.tokens;
|
||||
return verified;
|
||||
}
|
||||
return {
|
||||
err: new InvalidAccessTokenError()
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
120
packages/auth/src/error.ts
Normal file
120
packages/auth/src/error.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* A list of errors that can be thrown by OpenAuth.
|
||||
*
|
||||
* You can use these errors to check the type of error and handle it. For example.
|
||||
*
|
||||
* ```ts
|
||||
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||
*
|
||||
* if (err instanceof InvalidAuthorizationCodeError) {
|
||||
* // handle invalid code error
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* The OAuth server returned an error.
|
||||
*/
|
||||
export class OauthError extends Error {
|
||||
constructor(
|
||||
public error:
|
||||
| 'invalid_request'
|
||||
| 'invalid_grant'
|
||||
| 'unauthorized_client'
|
||||
| 'access_denied'
|
||||
| 'unsupported_grant_type'
|
||||
| 'server_error'
|
||||
| 'temporarily_unavailable',
|
||||
public description: string
|
||||
) {
|
||||
super(error + ' - ' + description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `provider` needs to be passed in.
|
||||
*/
|
||||
export class MissingProviderError extends OauthError {
|
||||
constructor() {
|
||||
super(
|
||||
'invalid_request',
|
||||
'Must specify `provider` query parameter if `select` callback on issuer is not specified'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given parameter is missing.
|
||||
*/
|
||||
export class MissingParameterError extends OauthError {
|
||||
constructor(public parameter: string) {
|
||||
super('invalid_request', 'Missing parameter: ' + parameter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given client is not authorized to use the redirect URI that was passed in.
|
||||
*/
|
||||
export class UnauthorizedClientError extends OauthError {
|
||||
constructor(
|
||||
public clientID: string,
|
||||
redirectURI: string
|
||||
) {
|
||||
super(
|
||||
'unauthorized_client',
|
||||
`Client ${clientID} is not authorized to use this redirect_uri: ${redirectURI}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser was in an unknown state.
|
||||
*
|
||||
* This can happen when certain cookies have expired. Or the browser was switched in the middle
|
||||
* of the authentication flow.
|
||||
*/
|
||||
export class UnknownStateError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given subject is invalid.
|
||||
*/
|
||||
export class InvalidSubjectError extends Error {
|
||||
constructor() {
|
||||
super('Invalid subject');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given refresh token is invalid.
|
||||
*/
|
||||
export class InvalidRefreshTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given access token is invalid.
|
||||
*/
|
||||
export class InvalidAccessTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given authorization code is invalid.
|
||||
*/
|
||||
export class InvalidAuthorizationCodeError extends Error {
|
||||
constructor() {
|
||||
super('Invalid authorization code');
|
||||
}
|
||||
}
|
||||
26
packages/auth/src/index.ts
Normal file
26
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `import { createClient } from "@openauthjs/openauth/client"` instead - it will tree shake better
|
||||
*/
|
||||
createClient
|
||||
} from './client.js';
|
||||
|
||||
export {
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `import { createSubjects } from "@openauthjs/openauth/subject"` instead - it will tree shake better
|
||||
*/
|
||||
createSubjects
|
||||
} from './subject.js';
|
||||
|
||||
import { issuer } from './issuer.js';
|
||||
|
||||
export {
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `import { issuer } from "@openauthjs/openauth"` instead, it was renamed
|
||||
*/
|
||||
issuer as authorizer,
|
||||
issuer
|
||||
};
|
||||
1136
packages/auth/src/issuer.ts
Normal file
1136
packages/auth/src/issuer.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
packages/auth/src/jwt.ts
Normal file
13
packages/auth/src/jwt.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { JWTPayload, jwtVerify, KeyLike, SignJWT } from 'jose';
|
||||
|
||||
export namespace jwt {
|
||||
export function create(payload: JWTPayload, algorithm: string, privateKey: KeyLike) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: algorithm, typ: 'JWT', kid: 'sst' })
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
export function verify<T>(token: string, publicKey: KeyLike) {
|
||||
return jwtVerify<T>(token, publicKey);
|
||||
}
|
||||
}
|
||||
136
packages/auth/src/keys.ts
Normal file
136
packages/auth/src/keys.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
exportJWK,
|
||||
exportPKCS8,
|
||||
exportSPKI,
|
||||
generateKeyPair,
|
||||
importPKCS8,
|
||||
importSPKI,
|
||||
JWK,
|
||||
KeyLike
|
||||
} from 'jose';
|
||||
|
||||
import { Storage, StorageAdapter } from './storage/storage.js';
|
||||
|
||||
const signingAlg = 'ES256';
|
||||
const encryptionAlg = 'RSA-OAEP-512';
|
||||
|
||||
interface SerializedKeyPair {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
created: number;
|
||||
alg: string;
|
||||
expired?: number;
|
||||
}
|
||||
|
||||
export interface KeyPair {
|
||||
id: string;
|
||||
alg: string;
|
||||
public: KeyLike;
|
||||
private: KeyLike;
|
||||
created: Date;
|
||||
expired?: Date;
|
||||
jwk: JWK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `signingKeys` instead
|
||||
*/
|
||||
export async function legacySigningKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||
const alg = 'RS512';
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(storage, ['oauth:key']);
|
||||
for await (const [_key, value] of scanner) {
|
||||
const publicKey = await importSPKI(value.publicKey, alg, {
|
||||
extractable: true
|
||||
});
|
||||
const privateKey = await importPKCS8(value.privateKey, alg);
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = value.id;
|
||||
results.push({
|
||||
id: value.id,
|
||||
alg,
|
||||
created: new Date(value.created),
|
||||
public: publicKey,
|
||||
private: privateKey,
|
||||
expired: new Date(1735858114000),
|
||||
jwk
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function signingKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(storage, ['signing:key']);
|
||||
for await (const [_key, value] of scanner) {
|
||||
const publicKey = await importSPKI(value.publicKey, value.alg, {
|
||||
extractable: true
|
||||
});
|
||||
const privateKey = await importPKCS8(value.privateKey, value.alg);
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = value.id;
|
||||
jwk.use = 'sig';
|
||||
results.push({
|
||||
id: value.id,
|
||||
alg: signingAlg,
|
||||
created: new Date(value.created),
|
||||
expired: value.expired ? new Date(value.expired) : undefined,
|
||||
public: publicKey,
|
||||
private: privateKey,
|
||||
jwk
|
||||
});
|
||||
}
|
||||
results.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||
if (results.filter((item) => !item.expired).length) return results;
|
||||
|
||||
const key = await generateKeyPair(signingAlg, {
|
||||
extractable: true
|
||||
});
|
||||
const serialized: SerializedKeyPair = {
|
||||
id: crypto.randomUUID(),
|
||||
publicKey: await exportSPKI(key.publicKey),
|
||||
privateKey: await exportPKCS8(key.privateKey),
|
||||
created: Date.now(),
|
||||
alg: signingAlg
|
||||
};
|
||||
await Storage.set(storage, ['signing:key', serialized.id], serialized);
|
||||
return signingKeys(storage);
|
||||
}
|
||||
|
||||
export async function encryptionKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(storage, ['encryption:key']);
|
||||
for await (const [_key, value] of scanner) {
|
||||
const publicKey = await importSPKI(value.publicKey, value.alg, {
|
||||
extractable: true
|
||||
});
|
||||
const privateKey = await importPKCS8(value.privateKey, value.alg);
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = value.id;
|
||||
results.push({
|
||||
id: value.id,
|
||||
alg: encryptionAlg,
|
||||
created: new Date(value.created),
|
||||
expired: value.expired ? new Date(value.expired) : undefined,
|
||||
public: publicKey,
|
||||
private: privateKey,
|
||||
jwk
|
||||
});
|
||||
}
|
||||
results.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||
if (results.filter((item) => !item.expired).length) return results;
|
||||
|
||||
const key = await generateKeyPair(encryptionAlg, {
|
||||
extractable: true
|
||||
});
|
||||
const serialized: SerializedKeyPair = {
|
||||
id: crypto.randomUUID(),
|
||||
publicKey: await exportSPKI(key.publicKey),
|
||||
privateKey: await exportPKCS8(key.privateKey),
|
||||
created: Date.now(),
|
||||
alg: encryptionAlg
|
||||
};
|
||||
await Storage.set(storage, ['encryption:key', serialized.id], serialized);
|
||||
return encryptionKeys(storage);
|
||||
}
|
||||
38
packages/auth/src/pkce.ts
Normal file
38
packages/auth/src/pkce.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { base64url } from 'jose';
|
||||
|
||||
function generateVerifier(length: number): string {
|
||||
const buffer = new Uint8Array(length);
|
||||
crypto.getRandomValues(buffer);
|
||||
return base64url.encode(buffer);
|
||||
}
|
||||
|
||||
async function generateChallenge(verifier: string, method: 'S256' | 'plain') {
|
||||
if (method === 'plain') return verifier;
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64url.encode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
export async function generatePKCE(length: number = 64) {
|
||||
if (length < 43 || length > 128) {
|
||||
throw new Error('Code verifier length must be between 43 and 128 characters');
|
||||
}
|
||||
const verifier = generateVerifier(length);
|
||||
const challenge = await generateChallenge(verifier, 'S256');
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256'
|
||||
};
|
||||
}
|
||||
|
||||
export async function validatePKCE(
|
||||
verifier: string,
|
||||
challenge: string,
|
||||
method: 'S256' | 'plain' = 'S256'
|
||||
) {
|
||||
const generatedChallenge = await generateChallenge(verifier, method);
|
||||
// timing safe equals?
|
||||
return generatedChallenge === challenge;
|
||||
}
|
||||
127
packages/auth/src/provider/apple.ts
Normal file
127
packages/auth/src/provider/apple.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Apple. Supports both OAuth2 and OIDC.
|
||||
*
|
||||
* #### Using OAuth
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { AppleProvider } from "@openauthjs/openauth/provider/apple"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* apple: AppleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Using OAuth with form_post response mode
|
||||
*
|
||||
* When requesting name or email scopes from Apple, you must use form_post response mode:
|
||||
*
|
||||
* ```ts {5-9}
|
||||
* import { AppleProvider } from "@openauthjs/openauth/provider/apple"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* apple: AppleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321",
|
||||
* responseMode: "form_post"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Using OIDC
|
||||
*
|
||||
* ```ts {5-7}
|
||||
* import { AppleOidcProvider } from "@openauthjs/openauth/provider/apple"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* apple: AppleOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||
|
||||
export interface AppleConfig extends Oauth2WrappedConfig {
|
||||
/**
|
||||
* The response mode to use for the authorization request.
|
||||
* Apple requires 'form_post' response mode when requesting name or email scopes.
|
||||
* @default "query"
|
||||
*/
|
||||
responseMode?: 'query' | 'form_post';
|
||||
}
|
||||
export interface AppleOidcConfig extends OidcWrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create an Apple OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* // Using default query response mode (GET callback)
|
||||
* AppleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
*
|
||||
* // Using form_post response mode (POST callback)
|
||||
* // Required when requesting name or email scope
|
||||
* AppleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321",
|
||||
* responseMode: "form_post",
|
||||
* scopes: ["name", "email"]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function AppleProvider(config: AppleConfig) {
|
||||
const { responseMode, ...restConfig } = config;
|
||||
const additionalQuery =
|
||||
responseMode === 'form_post'
|
||||
? { response_mode: 'form_post', ...config.query }
|
||||
: config.query || {};
|
||||
|
||||
return Oauth2Provider({
|
||||
...restConfig,
|
||||
type: 'apple' as const,
|
||||
endpoint: {
|
||||
authorization: 'https://appleid.apple.com/auth/authorize',
|
||||
token: 'https://appleid.apple.com/auth/token',
|
||||
jwks: 'https://appleid.apple.com/auth/keys'
|
||||
},
|
||||
query: additionalQuery
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Apple OIDC provider.
|
||||
*
|
||||
* This is useful if you just want to verify the user's email address.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* AppleOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function AppleOidcProvider(config: AppleOidcConfig) {
|
||||
return OidcProvider({
|
||||
...config,
|
||||
type: 'apple' as const,
|
||||
issuer: 'https://appleid.apple.com'
|
||||
});
|
||||
}
|
||||
66
packages/auth/src/provider/arctic.ts
Normal file
66
packages/auth/src/provider/arctic.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { OAuth2Tokens } from 'arctic';
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { OauthError } from '../error.js';
|
||||
import { getRelativeUrl } from '../util.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
export interface ArcticProviderOptions {
|
||||
scopes: string[];
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProviderState {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function ArcticProvider(
|
||||
provider: new (
|
||||
clientID: string,
|
||||
clientSecret: string,
|
||||
callback: string
|
||||
) => {
|
||||
createAuthorizationURL(state: string, scopes: string[]): URL;
|
||||
validateAuthorizationCode(code: string): Promise<OAuth2Tokens>;
|
||||
refreshAccessToken(refreshToken: string): Promise<OAuth2Tokens>;
|
||||
},
|
||||
config: ArcticProviderOptions
|
||||
): Provider<{
|
||||
tokenset: OAuth2Tokens;
|
||||
}> {
|
||||
function getClient(c: Context) {
|
||||
const callback = new URL(c.req.url);
|
||||
const pathname = callback.pathname.replace(/authorize.*$/, 'callback');
|
||||
const url = getRelativeUrl(c, pathname);
|
||||
return new provider(config.clientID, config.clientSecret, url);
|
||||
}
|
||||
return {
|
||||
type: 'arctic',
|
||||
init(routes, ctx) {
|
||||
routes.get('/authorize', async (c) => {
|
||||
const client = getClient(c);
|
||||
const state = crypto.randomUUID();
|
||||
await ctx.set(c, 'provider', 60 * 10, {
|
||||
state
|
||||
});
|
||||
return c.redirect(client.createAuthorizationURL(state, config.scopes));
|
||||
});
|
||||
|
||||
routes.get('/callback', async (c) => {
|
||||
const client = getClient(c);
|
||||
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||
if (!provider) return c.redirect('../authorize');
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
if (!code) throw new Error('Missing code');
|
||||
if (state !== provider.state) throw new OauthError('invalid_request', 'Invalid state');
|
||||
const tokens = await client.validateAuthorizationCode(code);
|
||||
return ctx.success(c, {
|
||||
tokenset: tokens
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
215
packages/auth/src/provider/code.ts
Normal file
215
packages/auth/src/provider/code.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Configures a provider that supports pin code authentication. This is usually paired with the
|
||||
* `CodeUI`.
|
||||
*
|
||||
* ```ts
|
||||
* import { CodeUI } from "@openauthjs/openauth/ui/code"
|
||||
* import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* code: CodeProvider(
|
||||
* CodeUI({
|
||||
* copy: {
|
||||
* code_info: "We'll send a pin code to your email"
|
||||
* },
|
||||
* sendCode: (claims, code) => console.log(claims.email, code)
|
||||
* })
|
||||
* )
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* You can customize the provider using.
|
||||
*
|
||||
* ```ts {7-9}
|
||||
* const ui = CodeUI({
|
||||
* // ...
|
||||
* })
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* code: CodeProvider(
|
||||
* { ...ui, length: 4 }
|
||||
* )
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Behind the scenes, the `CodeProvider` expects callbacks that implements request handlers
|
||||
* that generate the UI for the following.
|
||||
*
|
||||
* ```ts
|
||||
* CodeProvider({
|
||||
* // ...
|
||||
* request: (req, state, form, error) => Promise<Response>
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* This allows you to create your own UI.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from '../random.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
export interface CodeProviderConfig<
|
||||
Claims extends Record<string, string> = Record<string, string>
|
||||
> {
|
||||
/**
|
||||
* The length of the pin code.
|
||||
*
|
||||
* @default 6
|
||||
*/
|
||||
length?: number;
|
||||
/**
|
||||
* The request handler to generate the UI for the code flow.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
request: (
|
||||
req: Request,
|
||||
state: CodeProviderState,
|
||||
form?: FormData,
|
||||
error?: CodeProviderError
|
||||
) => Promise<Response>;
|
||||
/**
|
||||
* Callback to send the pin code to the user.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* sendCode: async (claims, code) => {
|
||||
* // Send the code through the email or phone number based on the claims
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
sendCode: (claims: Claims, code: string) => Promise<void | CodeProviderError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the code flow.
|
||||
*
|
||||
* | State | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `start` | The user is asked to enter their email address or phone number to start the flow. |
|
||||
* | `code` | The user needs to enter the pin code to verify their _claim_. |
|
||||
*/
|
||||
export type CodeProviderState =
|
||||
| {
|
||||
type: 'start';
|
||||
}
|
||||
| {
|
||||
type: 'code';
|
||||
resend?: boolean;
|
||||
code: string;
|
||||
claims: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The errors that can happen on the code flow.
|
||||
*
|
||||
* | Error | Description |
|
||||
* | ----- | ----------- |
|
||||
* | `invalid_code` | The code is invalid. |
|
||||
* | `invalid_claim` | The _claim_, email or phone number, is invalid. |
|
||||
*/
|
||||
export type CodeProviderError =
|
||||
| {
|
||||
type: 'invalid_code';
|
||||
}
|
||||
| {
|
||||
type: 'invalid_claim';
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function CodeProvider<Claims extends Record<string, string> = Record<string, string>>(
|
||||
config: CodeProviderConfig<Claims>
|
||||
): Provider<{ claims: Claims }> {
|
||||
const length = config.length || 6;
|
||||
function generate() {
|
||||
return generateUnbiasedDigits(length);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'code',
|
||||
init(routes, ctx) {
|
||||
async function transition(
|
||||
c: Context,
|
||||
next: CodeProviderState,
|
||||
fd?: FormData,
|
||||
err?: CodeProviderError
|
||||
) {
|
||||
await ctx.set<CodeProviderState>(c, 'provider', 60 * 60 * 24, next);
|
||||
const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err));
|
||||
return resp;
|
||||
}
|
||||
routes.get('/authorize', async (c) => {
|
||||
const resp = await transition(c, {
|
||||
type: 'start'
|
||||
});
|
||||
return resp;
|
||||
});
|
||||
|
||||
routes.post('/authorize', async (c) => {
|
||||
const code = generate();
|
||||
const fd = await c.req.formData();
|
||||
const state = await ctx.get<CodeProviderState>(c, 'provider');
|
||||
const action = fd.get('action')?.toString();
|
||||
|
||||
if (action === 'request' || action === 'resend') {
|
||||
const claims = Object.fromEntries(fd) as Claims;
|
||||
delete claims.action;
|
||||
const err = await config.sendCode(claims, code);
|
||||
if (err) return transition(c, { type: 'start' }, fd, err);
|
||||
return transition(
|
||||
c,
|
||||
{
|
||||
type: 'code',
|
||||
resend: action === 'resend',
|
||||
claims,
|
||||
code
|
||||
},
|
||||
fd
|
||||
);
|
||||
}
|
||||
|
||||
if (fd.get('action')?.toString() === 'verify' && state.type === 'code') {
|
||||
const fd = await c.req.formData();
|
||||
const compare = fd.get('code')?.toString();
|
||||
if (!state.code || !compare || !timingSafeCompare(state.code, compare)) {
|
||||
return transition(
|
||||
c,
|
||||
{
|
||||
...state,
|
||||
resend: false
|
||||
},
|
||||
fd,
|
||||
{ type: 'invalid_code' }
|
||||
);
|
||||
}
|
||||
await ctx.unset(c, 'provider');
|
||||
return ctx.forward(c, await ctx.success(c, { claims: state.claims as Claims }));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type CodeProviderOptions = Parameters<typeof CodeProvider>[0];
|
||||
74
packages/auth/src/provider/cognito.ts
Normal file
74
packages/auth/src/provider/cognito.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Use this provider to authenticate with a Cognito OAuth endpoint.
|
||||
*
|
||||
* ```ts {5-10}
|
||||
* import { CognitoProvider } from "@openauthjs/openauth/provider/cognito"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* cognito: CognitoProvider({
|
||||
* domain: "your-domain.auth.us-east-1.amazoncognito.com",
|
||||
* region: "us-east-1",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface CognitoConfig extends Oauth2WrappedConfig {
|
||||
/**
|
||||
* The domain of the Cognito User Pool.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* domain: "your-domain.auth.us-east-1.amazoncognito.com"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
domain: string;
|
||||
/**
|
||||
* The region the Cognito User Pool is in.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* region: "us-east-1"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
region: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Cognito OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* CognitoProvider({
|
||||
* domain: "your-domain.auth.us-east-1.amazoncognito.com",
|
||||
* region: "us-east-1",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function CognitoProvider(config: CognitoConfig) {
|
||||
const domain = `${config.domain}.auth.${config.region}.amazoncognito.com`;
|
||||
|
||||
return Oauth2Provider({
|
||||
type: 'cognito',
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: `https://${domain}/oauth2/authorize`,
|
||||
token: `https://${domain}/oauth2/token`
|
||||
}
|
||||
});
|
||||
}
|
||||
45
packages/auth/src/provider/discord.ts
Normal file
45
packages/auth/src/provider/discord.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Discord.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { DiscordProvider } from "@openauthjs/openauth/provider/discord"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* discord: DiscordProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface DiscordConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Discord OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* DiscordProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function DiscordProvider(config: DiscordConfig) {
|
||||
return Oauth2Provider({
|
||||
type: 'discord',
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: 'https://discord.com/oauth2/authorize',
|
||||
token: 'https://discord.com/api/oauth2/token'
|
||||
}
|
||||
});
|
||||
}
|
||||
84
packages/auth/src/provider/facebook.ts
Normal file
84
packages/auth/src/provider/facebook.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Facebook. Supports both OAuth2 and OIDC.
|
||||
*
|
||||
* #### Using OAuth
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { FacebookProvider } from "@openauthjs/openauth/provider/facebook"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* facebook: FacebookProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Using OIDC
|
||||
*
|
||||
* ```ts {5-7}
|
||||
* import { FacebookOidcProvider } from "@openauthjs/openauth/provider/facebook"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* facebook: FacebookOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||
|
||||
export interface FacebookConfig extends Oauth2WrappedConfig {}
|
||||
export interface FacebookOidcConfig extends OidcWrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Facebook OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* FacebookProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function FacebookProvider(config: FacebookConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'facebook',
|
||||
endpoint: {
|
||||
authorization: 'https://www.facebook.com/v12.0/dialog/oauth',
|
||||
token: 'https://graph.facebook.com/v12.0/oauth/access_token'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Facebook OIDC provider.
|
||||
*
|
||||
* This is useful if you just want to verify the user's email address.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* FacebookOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function FacebookOidcProvider(config: FacebookOidcConfig) {
|
||||
return OidcProvider({
|
||||
...config,
|
||||
type: 'facebook',
|
||||
issuer: 'https://graph.facebook.com'
|
||||
});
|
||||
}
|
||||
45
packages/auth/src/provider/github.ts
Normal file
45
packages/auth/src/provider/github.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Github.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { GithubProvider } from "@openauthjs/openauth/provider/github"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* github: GithubProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface GithubConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Github OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* GithubProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function GithubProvider(config: GithubConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'github',
|
||||
endpoint: {
|
||||
authorization: 'https://github.com/login/oauth/authorize',
|
||||
token: 'https://github.com/login/oauth/access_token'
|
||||
}
|
||||
});
|
||||
}
|
||||
85
packages/auth/src/provider/google.ts
Normal file
85
packages/auth/src/provider/google.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Google. Supports both OAuth2 and OIDC.
|
||||
*
|
||||
* #### Using OAuth
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { GoogleProvider } from "@openauthjs/openauth/provider/google"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* google: GoogleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Using OIDC
|
||||
*
|
||||
* ```ts {5-7}
|
||||
* import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* google: GoogleOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||
|
||||
export interface GoogleConfig extends Oauth2WrappedConfig {}
|
||||
export interface GoogleOidcConfig extends OidcWrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Google OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* GoogleProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function GoogleProvider(config: GoogleConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'google',
|
||||
endpoint: {
|
||||
authorization: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
token: 'https://oauth2.googleapis.com/token',
|
||||
jwks: 'https://www.googleapis.com/oauth2/v3/certs'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Google OIDC provider.
|
||||
*
|
||||
* This is useful if you just want to verify the user's email address.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* GoogleOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function GoogleOidcProvider(config: GoogleOidcConfig) {
|
||||
return OidcProvider({
|
||||
...config,
|
||||
type: 'google',
|
||||
issuer: 'https://accounts.google.com'
|
||||
});
|
||||
}
|
||||
5
packages/auth/src/provider/index.ts
Normal file
5
packages/auth/src/provider/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './code.js';
|
||||
export type { Provider } from './provider.js';
|
||||
export * from './spotify.js';
|
||||
export * from './ssh.js';
|
||||
export * from './steam.js';
|
||||
45
packages/auth/src/provider/jumpcloud.ts
Normal file
45
packages/auth/src/provider/jumpcloud.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with JumpCloud.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { JumpCloudProvider } from "@openauthjs/openauth/provider/jumpcloud"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* jumpcloud: JumpCloudProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface JumpCloudConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a JumpCloud OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* JumpCloudProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function JumpCloudProvider(config: JumpCloudConfig) {
|
||||
return Oauth2Provider({
|
||||
type: 'jumpcloud',
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: 'https://oauth.id.jumpcloud.com/oauth2/auth',
|
||||
token: 'https://oauth.id.jumpcloud.com/oauth2/token'
|
||||
}
|
||||
});
|
||||
}
|
||||
75
packages/auth/src/provider/keycloak.ts
Normal file
75
packages/auth/src/provider/keycloak.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Use this provider to authenticate with a Keycloak server.
|
||||
*
|
||||
* ```ts {5-10}
|
||||
* import { KeycloakProvider } from "@openauthjs/openauth/provider/keycloak"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* keycloak: KeycloakProvider({
|
||||
* baseUrl: "https://your-keycloak-domain",
|
||||
* realm: "your-realm",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface KeycloakConfig extends Oauth2WrappedConfig {
|
||||
/**
|
||||
* The base URL of the Keycloak server.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* baseUrl: "https://your-keycloak-domain"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
baseUrl: string;
|
||||
/**
|
||||
* The realm in the Keycloak server to authenticate against.
|
||||
*
|
||||
* A realm in Keycloak is like a tenant or namespace that manages a set of
|
||||
* users, credentials, roles, and groups.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* realm: "your-realm"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
realm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Keycloak OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* KeycloakProvider({
|
||||
* baseUrl: "https://your-keycloak-domain",
|
||||
* realm: "your-realm",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function KeycloakProvider(config: KeycloakConfig) {
|
||||
const baseConfig = {
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/auth`,
|
||||
token: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/token`
|
||||
}
|
||||
};
|
||||
return Oauth2Provider(baseConfig);
|
||||
}
|
||||
12
packages/auth/src/provider/linkedin.ts
Normal file
12
packages/auth/src/provider/linkedin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export function LinkedInAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'linkedin',
|
||||
endpoint: {
|
||||
authorization: 'https://www.linkedin.com/oauth/v2/authorization',
|
||||
token: 'https://www.linkedin.com/oauth/v2/accessToken'
|
||||
}
|
||||
});
|
||||
}
|
||||
100
packages/auth/src/provider/microsoft.ts
Normal file
100
packages/auth/src/provider/microsoft.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Microsoft. Supports both OAuth2 and OIDC.
|
||||
*
|
||||
* #### Using OAuth
|
||||
*
|
||||
* ```ts {5-9}
|
||||
* import { MicrosoftProvider } from "@openauthjs/openauth/provider/microsoft"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* microsoft: MicrosoftProvider({
|
||||
* tenant: "1234567890",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Using OIDC
|
||||
*
|
||||
* ```ts {5-7}
|
||||
* import { MicrosoftOidcProvider } from "@openauthjs/openauth/provider/microsoft"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* microsoft: MicrosoftOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||
|
||||
export interface MicrosoftConfig extends Oauth2WrappedConfig {
|
||||
/**
|
||||
* The tenant ID of the Microsoft account.
|
||||
*
|
||||
* This is usually the same as the client ID.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* tenant: "1234567890"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
tenant: string;
|
||||
}
|
||||
export interface MicrosoftOidcConfig extends OidcWrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Microsoft OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* MicrosoftProvider({
|
||||
* tenant: "1234567890",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function MicrosoftProvider(config: MicrosoftConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'microsoft',
|
||||
endpoint: {
|
||||
authorization: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/authorize`,
|
||||
token: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/token`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Microsoft OIDC provider.
|
||||
*
|
||||
* This is useful if you just want to verify the user's email address.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* MicrosoftOidcProvider({
|
||||
* clientID: "1234567890"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function MicrosoftOidcProvider(config: MicrosoftOidcConfig) {
|
||||
return OidcProvider({
|
||||
...config,
|
||||
type: 'microsoft',
|
||||
issuer: 'https://graph.microsoft.com/oidc/userinfo'
|
||||
});
|
||||
}
|
||||
282
packages/auth/src/provider/oauth2.ts
Normal file
282
packages/auth/src/provider/oauth2.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Use this to connect authentication providers that support OAuth 2.0.
|
||||
*
|
||||
* ```ts {5-12}
|
||||
* import { Oauth2Provider } from "@openauthjs/openauth/provider/oauth2"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* oauth2: Oauth2Provider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321",
|
||||
* endpoint: {
|
||||
* authorization: "https://auth.myserver.com/authorize",
|
||||
* token: "https://auth.myserver.com/token"
|
||||
* }
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
import { OauthError } from '../error.js';
|
||||
import { generatePKCE } from '../pkce.js';
|
||||
import { getRelativeUrl } from '../util.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
export interface Oauth2Config {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* The client ID.
|
||||
*
|
||||
* This is just a string to identify your app.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* clientID: "my-client"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
clientID: string;
|
||||
/**
|
||||
* The client secret.
|
||||
*
|
||||
* This is a private key that's used to authenticate your app. It should be kept secret.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* clientSecret: "0987654321"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
clientSecret: string;
|
||||
/**
|
||||
* The URLs of the authorization and token endpoints.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* endpoint: {
|
||||
* authorization: "https://auth.myserver.com/authorize",
|
||||
* token: "https://auth.myserver.com/token",
|
||||
* jwks: "https://auth.myserver.com/auth/keys"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
endpoint: {
|
||||
/**
|
||||
* The URL of the authorization endpoint.
|
||||
*/
|
||||
authorization: string;
|
||||
/**
|
||||
* The URL of the token endpoint.
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* The URL of the JWKS endpoint.
|
||||
*/
|
||||
jwks?: string;
|
||||
};
|
||||
/**
|
||||
* A list of OAuth scopes that you want to request.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* scopes: ["email", "profile"]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
scopes: string[];
|
||||
/**
|
||||
* Whether to use PKCE (Proof Key for Code Exchange) for the authorization code flow.
|
||||
* Some providers like x.com require this.
|
||||
* @default false
|
||||
*/
|
||||
pkce?: boolean;
|
||||
/**
|
||||
* Any additional parameters that you want to pass to the authorization endpoint.
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* query: {
|
||||
* access_type: "offline",
|
||||
* prompt: "consent"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type Oauth2WrappedConfig = Omit<Oauth2Config, 'endpoint' | 'name'>;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface Oauth2Token {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expiry: number;
|
||||
id?: Record<string, any>;
|
||||
raw: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ProviderState {
|
||||
state: string;
|
||||
redirect: string;
|
||||
codeVerifier?: string;
|
||||
}
|
||||
|
||||
export function Oauth2Provider(
|
||||
config: Oauth2Config
|
||||
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
|
||||
const query = config.query || {};
|
||||
|
||||
// Helper function to handle token exchange and response building
|
||||
async function handleCallbackLogic(
|
||||
c: any,
|
||||
ctx: any,
|
||||
provider: ProviderState,
|
||||
code: string | undefined
|
||||
) {
|
||||
if (!provider || !code) {
|
||||
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientID,
|
||||
client_secret: config.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: provider.redirect,
|
||||
...(provider.codeVerifier ? { code_verifier: provider.codeVerifier } : {})
|
||||
});
|
||||
|
||||
const json: any = await fetch(config.endpoint.token, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: body.toString()
|
||||
}).then((r) => r.json());
|
||||
|
||||
if ('error' in json) {
|
||||
throw new OauthError(json.error, json.error_description);
|
||||
}
|
||||
|
||||
let idTokenPayload: Record<string, any> | null = null;
|
||||
if (config.endpoint.jwks) {
|
||||
const jwksEndpoint = new URL(config.endpoint.jwks);
|
||||
// @ts-expect-error bun/node mismatch
|
||||
const jwks = createRemoteJWKSet(jwksEndpoint);
|
||||
const { payload } = await jwtVerify(json.id_token, jwks, {
|
||||
audience: config.clientID
|
||||
});
|
||||
idTokenPayload = payload;
|
||||
}
|
||||
|
||||
return ctx.success(c, {
|
||||
clientID: config.clientID,
|
||||
tokenset: {
|
||||
get access() {
|
||||
return json.access_token;
|
||||
},
|
||||
get refresh() {
|
||||
return json.refresh_token;
|
||||
},
|
||||
get expiry() {
|
||||
return json.expires_in;
|
||||
},
|
||||
get id() {
|
||||
if (!idTokenPayload) return null;
|
||||
return idTokenPayload;
|
||||
},
|
||||
get raw() {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: config.type || 'oauth2',
|
||||
init(routes, ctx) {
|
||||
routes.get('/authorize', async (c) => {
|
||||
const state = crypto.randomUUID();
|
||||
const pkce = config.pkce ? await generatePKCE() : undefined;
|
||||
await ctx.set<ProviderState>(c, 'provider', 60 * 10, {
|
||||
state,
|
||||
redirect: getRelativeUrl(c, './callback'),
|
||||
codeVerifier: pkce?.verifier
|
||||
});
|
||||
const authorization = new URL(config.endpoint.authorization);
|
||||
authorization.searchParams.set('client_id', config.clientID);
|
||||
authorization.searchParams.set('redirect_uri', getRelativeUrl(c, './callback'));
|
||||
authorization.searchParams.set('response_type', 'code');
|
||||
authorization.searchParams.set('state', state);
|
||||
authorization.searchParams.set('scope', config.scopes.join(' '));
|
||||
if (pkce) {
|
||||
authorization.searchParams.set('code_challenge', pkce.challenge);
|
||||
authorization.searchParams.set('code_challenge_method', pkce.method);
|
||||
}
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
authorization.searchParams.set(key, value);
|
||||
}
|
||||
return c.redirect(authorization.toString());
|
||||
});
|
||||
|
||||
routes.get('/callback', async (c) => {
|
||||
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
const error = c.req.query('error');
|
||||
|
||||
if (error)
|
||||
throw new OauthError(
|
||||
error.toString() as any,
|
||||
c.req.query('error_description')?.toString() || ''
|
||||
);
|
||||
if (!provider || !code || (provider.state && state !== provider.state)) {
|
||||
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||
}
|
||||
|
||||
return handleCallbackLogic(c, ctx, provider, code);
|
||||
});
|
||||
|
||||
routes.post('/callback', async (c) => {
|
||||
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||
|
||||
// Handle form data from POST request
|
||||
const formData = await c.req.formData();
|
||||
const code = formData.get('code')?.toString();
|
||||
const state = formData.get('state')?.toString();
|
||||
const error = formData.get('error')?.toString();
|
||||
|
||||
if (error)
|
||||
throw new OauthError(error as any, formData.get('error_description')?.toString() || '');
|
||||
|
||||
if (!provider || !code || (provider.state && state !== provider.state)) {
|
||||
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||
}
|
||||
|
||||
return handleCallbackLogic(c, ctx, provider, code);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
173
packages/auth/src/provider/oidc.ts
Normal file
173
packages/auth/src/provider/oidc.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Use this to connect authentication providers that support OIDC.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { OidcProvider } from "@openauthjs/openauth/provider/oidc"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* oauth2: OidcProvider({
|
||||
* clientId: "1234567890",
|
||||
* issuer: "https://auth.myserver.com"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { JWTPayload } from 'hono/utils/jwt/types';
|
||||
import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose';
|
||||
|
||||
import { WellKnown } from '../client.js';
|
||||
import { OauthError } from '../error.js';
|
||||
import { getRelativeUrl, lazy } from '../util.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
export interface OidcConfig {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* The client ID.
|
||||
*
|
||||
* This is just a string to identify your app.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* clientID: "my-client"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
clientID: string;
|
||||
/**
|
||||
* The URL of your authorization server.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* issuer: "https://auth.myserver.com"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
issuer: string;
|
||||
/**
|
||||
* A list of OIDC scopes that you want to request.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* scopes: ["openid", "profile", "email"]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
scopes?: string[];
|
||||
/**
|
||||
* Any additional parameters that you want to pass to the authorization endpoint.
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* query: {
|
||||
* prompt: "consent"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type OidcWrappedConfig = Omit<OidcConfig, 'issuer' | 'name'>;
|
||||
|
||||
interface ProviderState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
redirect: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface IdTokenResponse {
|
||||
idToken: string;
|
||||
claims: Record<string, any>;
|
||||
raw: Record<string, any>;
|
||||
}
|
||||
|
||||
export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; clientID: string }> {
|
||||
const query = config.query || {};
|
||||
const scopes = config.scopes || [];
|
||||
|
||||
const wk = lazy(() =>
|
||||
fetch(config.issuer + '/.well-known/openid-configuration').then(async (r) => {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json() as Promise<WellKnown>;
|
||||
})
|
||||
);
|
||||
|
||||
const jwks = lazy(() =>
|
||||
wk()
|
||||
.then((r) => r.jwks_uri)
|
||||
.then(async (uri) => {
|
||||
const r = await fetch(uri);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return createLocalJWKSet((await r.json()) as JSONWebKeySet);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
type: config.type || 'oidc',
|
||||
init(routes, ctx) {
|
||||
routes.get('/authorize', async (c) => {
|
||||
const provider: ProviderState = {
|
||||
state: crypto.randomUUID(),
|
||||
nonce: crypto.randomUUID(),
|
||||
redirect: getRelativeUrl(c, './callback')
|
||||
};
|
||||
await ctx.set(c, 'provider', 60 * 10, provider);
|
||||
const authorization = new URL(await wk().then((r) => r.authorization_endpoint));
|
||||
authorization.searchParams.set('client_id', config.clientID);
|
||||
authorization.searchParams.set('response_type', 'id_token');
|
||||
authorization.searchParams.set('response_mode', 'form_post');
|
||||
authorization.searchParams.set('state', provider.state);
|
||||
authorization.searchParams.set('nonce', provider.nonce);
|
||||
authorization.searchParams.set('redirect_uri', provider.redirect);
|
||||
authorization.searchParams.set('scope', ['openid', ...scopes].join(' '));
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
authorization.searchParams.set(key, value);
|
||||
}
|
||||
return c.redirect(authorization.toString());
|
||||
});
|
||||
|
||||
routes.post('/callback', async (c) => {
|
||||
const provider = await ctx.get<ProviderState>(c, 'provider');
|
||||
if (!provider) return c.redirect(getRelativeUrl(c, './authorize'));
|
||||
const body = await c.req.formData();
|
||||
const error = body.get('error');
|
||||
if (error)
|
||||
throw new OauthError(
|
||||
error.toString() as any,
|
||||
body.get('error_description')?.toString() || ''
|
||||
);
|
||||
const idToken = body.get('id_token');
|
||||
if (!idToken) throw new OauthError('invalid_request', 'Missing id_token');
|
||||
const result = await jwtVerify(idToken.toString(), await jwks(), {
|
||||
audience: config.clientID
|
||||
});
|
||||
if (result.payload.nonce !== provider.nonce) {
|
||||
throw new OauthError('invalid_request', 'Invalid nonce');
|
||||
}
|
||||
return ctx.success(c, {
|
||||
id: result.payload,
|
||||
clientID: config.clientID
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
34
packages/auth/src/provider/provider.ts
Normal file
34
packages/auth/src/provider/provider.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Context, Hono } from 'hono';
|
||||
|
||||
import { StorageAdapter } from '../storage/storage.js';
|
||||
|
||||
export type ProviderRoute = Hono;
|
||||
|
||||
export interface Provider<Properties = any> {
|
||||
type: string;
|
||||
init: (route: ProviderRoute, options: ProviderOptions<Properties>) => void;
|
||||
client?: (input: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
params: Record<string, string>;
|
||||
}) => Promise<Properties>;
|
||||
}
|
||||
|
||||
export interface ProviderOptions<Properties> {
|
||||
name: string;
|
||||
success: (
|
||||
ctx: Context,
|
||||
properties: Properties,
|
||||
opts?: {
|
||||
invalidate?: (subject: string) => Promise<void>;
|
||||
}
|
||||
) => Promise<Response>;
|
||||
forward: (ctx: Context, response: Response) => Response;
|
||||
set: <T>(ctx: Context, key: string, maxAge: number, value: T) => Promise<void>;
|
||||
get: <T>(ctx: Context, key: string) => Promise<T>;
|
||||
unset: (ctx: Context, key: string) => Promise<void>;
|
||||
invalidate: (subject: string) => Promise<void>;
|
||||
storage: StorageAdapter;
|
||||
}
|
||||
export class ProviderError extends Error {}
|
||||
export class ProviderUnknownError extends ProviderError {}
|
||||
67
packages/auth/src/provider/slack.ts
Normal file
67
packages/auth/src/provider/slack.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Slack.
|
||||
*
|
||||
* ```ts {5-10}
|
||||
* import { SlackProvider } from "@openauthjs/openauth/provider/slack"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* slack: SlackProvider({
|
||||
* team: "T1234567890",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321",
|
||||
* scopes: ["openid", "email", "profile"]
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface SlackConfig extends Oauth2WrappedConfig {
|
||||
/**
|
||||
* The workspace the user is intending to authenticate.
|
||||
*
|
||||
* If that workspace has been previously authenticated, the user will be signed in directly,
|
||||
* bypassing the consent screen.
|
||||
*/
|
||||
team: string;
|
||||
/**
|
||||
* The scopes to request from the user.
|
||||
*
|
||||
* | Scope | Description |
|
||||
* |-|-|
|
||||
* | `email` | Grants permission to access the user's email address. |
|
||||
* | `profile` | Grants permission to access the user's profile information. |
|
||||
* | `openid` | Grants permission to use OpenID Connect to verify the user's identity. |
|
||||
*/
|
||||
scopes: ('email' | 'profile' | 'openid')[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [Slack OAuth2 provider](https://api.slack.com/authentication/sign-in-with-slack).
|
||||
*
|
||||
* @param {SlackConfig} config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* SlackProvider({
|
||||
* team: "T1234567890",
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321",
|
||||
* scopes: ["openid", "email", "profile"]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function SlackProvider(config: SlackConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'slack',
|
||||
endpoint: {
|
||||
authorization: 'https://slack.com/openid/connect/authorize',
|
||||
token: 'https://slack.com/api/openid.connect.token'
|
||||
}
|
||||
});
|
||||
}
|
||||
45
packages/auth/src/provider/spotify.ts
Normal file
45
packages/auth/src/provider/spotify.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Spotify.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { SpotifyProvider } from "@openauthjs/openauth/provider/spotify"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* spotify: SpotifyProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface SpotifyConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Spotify OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* SpotifyProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function SpotifyProvider(config: SpotifyConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'spotify',
|
||||
endpoint: {
|
||||
authorization: 'https://accounts.spotify.com/authorize',
|
||||
token: 'https://accounts.spotify.com/api/token'
|
||||
}
|
||||
});
|
||||
}
|
||||
53
packages/auth/src/provider/ssh.ts
Normal file
53
packages/auth/src/provider/ssh.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import type { Provider } from './provider.js';
|
||||
|
||||
export interface SshProviderConfig {
|
||||
sshAuthKey: string;
|
||||
}
|
||||
|
||||
export interface SshLoginBody {
|
||||
fingerprint: string;
|
||||
steamId: string;
|
||||
username?: string;
|
||||
profile?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function SshProvider(config: SshProviderConfig): Provider<{
|
||||
fingerprint: string;
|
||||
steamId: string;
|
||||
username?: string;
|
||||
profile?: Record<string, unknown>;
|
||||
}> {
|
||||
return {
|
||||
type: 'ssh',
|
||||
init(routes, ctx) {
|
||||
routes.post('/login', async (c: Context) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
const bearer = authHeader.split(' ')[1];
|
||||
if (bearer !== config.sshAuthKey) {
|
||||
return c.json({ error: 'Invalid authorization token' }, 401);
|
||||
}
|
||||
|
||||
const body = (await c.req.json()) as SshLoginBody;
|
||||
if (!body.fingerprint) {
|
||||
return c.json({ error: 'Fingerprint is required' }, 400);
|
||||
}
|
||||
if (!body.steamId || !/^\d{17}$/.test(body.steamId)) {
|
||||
return c.json({ error: 'steamId is required and must be a 17-digit Steam ID' }, 400);
|
||||
}
|
||||
|
||||
return ctx.success(c, {
|
||||
fingerprint: body.fingerprint,
|
||||
steamId: body.steamId,
|
||||
username: body.username,
|
||||
profile: body.profile
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
52
packages/auth/src/provider/steam.ts
Normal file
52
packages/auth/src/provider/steam.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getRelativeUrl } from '../util.js';
|
||||
import { Provider } from './provider.js';
|
||||
|
||||
const STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login';
|
||||
|
||||
export function SteamProvider(): Provider<{ steamid: string }> {
|
||||
return {
|
||||
type: 'steam',
|
||||
init(routes, ctx) {
|
||||
routes.get('/authorize', async (c) => {
|
||||
const returnUrl = getRelativeUrl(c, './callback');
|
||||
const openidURL =
|
||||
`${STEAM_OPENID_URL}?` +
|
||||
`openid.ns=${encodeURIComponent('http://specs.openid.net/auth/2.0')}&` +
|
||||
`openid.mode=checkid_setup&` +
|
||||
`openid.return_to=${encodeURIComponent(returnUrl)}&` +
|
||||
`openid.realm=${encodeURIComponent(new URL(c.req.url).origin)}&` +
|
||||
`openid.identity=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}&` +
|
||||
`openid.claimed_id=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}`;
|
||||
return c.redirect(openidURL);
|
||||
});
|
||||
|
||||
routes.get('/callback', async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const verifyRes = await fetch(STEAM_OPENID_URL, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
...params,
|
||||
'openid.mode': 'check_authentication'
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
const verifyText = await verifyRes.text();
|
||||
if (!verifyText.includes('is_valid:true')) {
|
||||
throw new Error('Steam OpenID validation failed');
|
||||
}
|
||||
|
||||
const steamid = params['openid.claimed_id']?.split('/').pop();
|
||||
if (!steamid) {
|
||||
throw new Error('Steam ID not found');
|
||||
}
|
||||
|
||||
return ctx.success(c, { steamid });
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
45
packages/auth/src/provider/twitch.ts
Normal file
45
packages/auth/src/provider/twitch.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Twitch.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { TwitchProvider } from "@openauthjs/openauth/provider/twitch"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* twitch: TwitchProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface TwitchConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Twitch OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* TwitchProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function TwitchProvider(config: TwitchConfig) {
|
||||
return Oauth2Provider({
|
||||
type: 'twitch',
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: 'https://id.twitch.tv/oauth2/authorize',
|
||||
token: 'https://id.twitch.tv/oauth2/token'
|
||||
}
|
||||
});
|
||||
}
|
||||
46
packages/auth/src/provider/x.ts
Normal file
46
packages/auth/src/provider/x.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Use this provider to authenticate with X.com.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { XProvider } from "@openauthjs/openauth/provider/x"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* x: XProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface XProviderConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a X.com OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* XProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function XProvider(config: XProviderConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'x',
|
||||
endpoint: {
|
||||
authorization: 'https://twitter.com/i/oauth2/authorize',
|
||||
token: 'https://api.x.com/2/oauth2/token'
|
||||
},
|
||||
pkce: true
|
||||
});
|
||||
}
|
||||
45
packages/auth/src/provider/yahoo.ts
Normal file
45
packages/auth/src/provider/yahoo.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Use this provider to authenticate with Yahoo.
|
||||
*
|
||||
* ```ts {5-8}
|
||||
* import { YahooProvider } from "@openauthjs/openauth/provider/yahoo"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* yahoo: YahooProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||
|
||||
export interface YahooConfig extends Oauth2WrappedConfig {}
|
||||
|
||||
/**
|
||||
* Create a Yahoo OAuth2 provider.
|
||||
*
|
||||
* @param config - The config for the provider.
|
||||
* @example
|
||||
* ```ts
|
||||
* YahooProvider({
|
||||
* clientID: "1234567890",
|
||||
* clientSecret: "0987654321"
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function YahooProvider(config: YahooConfig) {
|
||||
return Oauth2Provider({
|
||||
...config,
|
||||
type: 'yahoo',
|
||||
endpoint: {
|
||||
authorization: 'https://api.login.yahoo.com/oauth2/request_auth',
|
||||
token: 'https://api.login.yahoo.com/oauth2/get_token'
|
||||
}
|
||||
});
|
||||
}
|
||||
24
packages/auth/src/random.ts
Normal file
24
packages/auth/src/random.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function generateUnbiasedDigits(length: number): string {
|
||||
const result: number[] = [];
|
||||
while (result.length < length) {
|
||||
const buffer = crypto.getRandomValues(new Uint8Array(length * 2));
|
||||
for (const byte of buffer) {
|
||||
if (byte < 250 && result.length < length) {
|
||||
result.push(byte % 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
export function timingSafeCompare(a: string, b: string): boolean {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
53
packages/auth/src/storage/aws.ts
Normal file
53
packages/auth/src/storage/aws.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AwsClient } from 'aws4fetch';
|
||||
|
||||
interface EC2Credentials {
|
||||
AccessKeyId: string;
|
||||
SecretAccessKey: string;
|
||||
Token: string;
|
||||
Expiration: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
let cachedCredentials: EC2Credentials | null = null;
|
||||
|
||||
async function getCredentials(url: string): Promise<EC2Credentials> {
|
||||
if (cachedCredentials) {
|
||||
const currentTime = new Date();
|
||||
const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60000);
|
||||
const expirationTime = new Date(cachedCredentials.Expiration);
|
||||
if (expirationTime > fiveMinutesFromNow) {
|
||||
return cachedCredentials;
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = (await fetch(url).then((res) => res.json())) as EC2Credentials;
|
||||
cachedCredentials = credentials;
|
||||
return credentials;
|
||||
}
|
||||
|
||||
export async function client(): Promise<AwsClient> {
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
return new AwsClient({
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||
region: process.env.AWS_REGION
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
|
||||
const credentials = await getCredentials(
|
||||
'http://169.254.170.2' + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
||||
);
|
||||
return new AwsClient({
|
||||
accessKeyId: credentials.AccessKeyId,
|
||||
secretAccessKey: credentials.SecretAccessKey,
|
||||
sessionToken: credentials.Token,
|
||||
region: process.env.AWS_REGION
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('No AWS credentials found');
|
||||
}
|
||||
|
||||
export type AwsOptions = Exclude<Parameters<AwsClient['fetch']>[1], null | undefined>['aws'];
|
||||
76
packages/auth/src/storage/cloudflare.ts
Normal file
76
packages/auth/src/storage/cloudflare.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Configure OpenAuth to use [Cloudflare KV](https://developers.cloudflare.com/kv/) as a
|
||||
* storage adapter.
|
||||
*
|
||||
* ```ts
|
||||
* import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
*
|
||||
* const storage = CloudflareStorage({
|
||||
* namespace: "my-namespace"
|
||||
* })
|
||||
*
|
||||
*
|
||||
* export default issuer({
|
||||
* storage,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import type { KVNamespace } from '@cloudflare/workers-types';
|
||||
|
||||
import { joinKey, splitKey, StorageAdapter } from './storage.js';
|
||||
|
||||
/**
|
||||
* Configure the Cloudflare KV store that's created.
|
||||
*/
|
||||
export interface CloudflareStorageOptions {
|
||||
namespace: KVNamespace;
|
||||
}
|
||||
/**
|
||||
* Creates a Cloudflare KV store.
|
||||
* @param options - The config for the adapter.
|
||||
*/
|
||||
export function CloudflareStorage(options: CloudflareStorageOptions): StorageAdapter {
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const value = await options.namespace.get(joinKey(key), 'json');
|
||||
if (!value) return;
|
||||
return value as Record<string, any>;
|
||||
},
|
||||
|
||||
async set(key: string[], value: any, expiry?: Date) {
|
||||
await options.namespace.put(joinKey(key), JSON.stringify(value), {
|
||||
expirationTtl: expiry
|
||||
? Math.max(Math.floor((expiry.getTime() - Date.now()) / 1000), 60)
|
||||
: undefined
|
||||
});
|
||||
},
|
||||
|
||||
async remove(key: string[]) {
|
||||
await options.namespace.delete(joinKey(key));
|
||||
},
|
||||
|
||||
async *scan(prefix: string[]) {
|
||||
let cursor: string | undefined;
|
||||
while (true) {
|
||||
const result = await options.namespace.list({
|
||||
prefix: joinKey([...prefix, '']),
|
||||
cursor
|
||||
});
|
||||
|
||||
for (const key of result.keys) {
|
||||
const value = await options.namespace.get(key.name, 'json');
|
||||
if (value !== null) {
|
||||
yield [splitKey(key.name), value];
|
||||
}
|
||||
}
|
||||
if (result.list_complete) {
|
||||
break;
|
||||
}
|
||||
cursor = result.cursor;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
189
packages/auth/src/storage/dynamo.ts
Normal file
189
packages/auth/src/storage/dynamo.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Configure OpenAuth to use [DynamoDB](https://aws.amazon.com/dynamodb/) as a storage adapter.
|
||||
*
|
||||
* ```ts
|
||||
* import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo"
|
||||
*
|
||||
* const storage = DynamoStorage({
|
||||
* table: "my-table",
|
||||
* pk: "pk",
|
||||
* sk: "sk"
|
||||
* })
|
||||
*
|
||||
* export default issuer({
|
||||
* storage,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { client } from './aws.js';
|
||||
import { joinKey, StorageAdapter } from './storage.js';
|
||||
|
||||
/**
|
||||
* Configure the DynamoDB table that's created.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* table: "my-table",
|
||||
* pk: "pk",
|
||||
* sk: "sk"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DynamoStorageOptions {
|
||||
/**
|
||||
* The name of the DynamoDB table.
|
||||
*/
|
||||
table: string;
|
||||
/**
|
||||
* The primary key column name.
|
||||
* @default "pk"
|
||||
*/
|
||||
pk?: string;
|
||||
/**
|
||||
* The sort key column name.
|
||||
* @default "sk"
|
||||
*/
|
||||
sk?: string;
|
||||
/**
|
||||
* Endpoint URL for the DynamoDB service. Useful for local testing.
|
||||
* @default "https://dynamodb.{region}.amazonaws.com"
|
||||
*/
|
||||
endpoint?: string;
|
||||
/**
|
||||
* The name of the time to live attribute.
|
||||
* @default "expiry"
|
||||
*/
|
||||
ttl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DynamoDB store.
|
||||
* @param options - The config for the adapter.
|
||||
*/
|
||||
export function DynamoStorage(options: DynamoStorageOptions): StorageAdapter {
|
||||
const pk = options.pk || 'pk';
|
||||
const sk = options.sk || 'sk';
|
||||
const ttl = options.ttl || 'expiry';
|
||||
const tableName = options.table;
|
||||
|
||||
function parseKey(key: string[]) {
|
||||
if (key.length === 2) {
|
||||
return {
|
||||
pk: key[0],
|
||||
sk: key[1]
|
||||
};
|
||||
}
|
||||
return {
|
||||
pk: joinKey(key.slice(0, 2)),
|
||||
sk: joinKey(key.slice(2))
|
||||
};
|
||||
}
|
||||
|
||||
async function dynamo(action: string, payload: any) {
|
||||
const c = await client();
|
||||
const endpoint = options.endpoint || `https://dynamodb.${c.region}.amazonaws.com`;
|
||||
const response = await c.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-amz-json-1.0',
|
||||
'X-Amz-Target': `DynamoDB_20120810.${action}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`DynamoDB request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<any>;
|
||||
}
|
||||
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const { pk: keyPk, sk: keySk } = parseKey(key);
|
||||
const params = {
|
||||
TableName: tableName,
|
||||
Key: {
|
||||
[pk]: { S: keyPk },
|
||||
[sk]: { S: keySk }
|
||||
}
|
||||
};
|
||||
const result = await dynamo('GetItem', params);
|
||||
if (!result.Item) return;
|
||||
if (result.Item[ttl] && result.Item[ttl].N < Date.now() / 1000) {
|
||||
return;
|
||||
}
|
||||
return JSON.parse(result.Item.value.S);
|
||||
},
|
||||
|
||||
async set(key: string[], value: any, expiry?: Date) {
|
||||
const parsed = parseKey(key);
|
||||
const params = {
|
||||
TableName: tableName,
|
||||
Item: {
|
||||
[pk]: { S: parsed.pk },
|
||||
[sk]: { S: parsed.sk },
|
||||
...(expiry
|
||||
? {
|
||||
[ttl]: { N: Math.floor(expiry.getTime() / 1000).toString() }
|
||||
}
|
||||
: {}),
|
||||
value: { S: JSON.stringify(value) }
|
||||
}
|
||||
};
|
||||
await dynamo('PutItem', params);
|
||||
},
|
||||
|
||||
async remove(key: string[]) {
|
||||
const { pk: keyPk, sk: keySk } = parseKey(key);
|
||||
const params = {
|
||||
TableName: tableName,
|
||||
Key: {
|
||||
[pk]: { S: keyPk },
|
||||
[sk]: { S: keySk }
|
||||
}
|
||||
};
|
||||
|
||||
await dynamo('DeleteItem', params);
|
||||
},
|
||||
|
||||
async *scan(prefix: string[]) {
|
||||
const prefixPk = prefix.length >= 2 ? joinKey(prefix.slice(0, 2)) : prefix[0];
|
||||
const prefixSk = prefix.length > 2 ? joinKey(prefix.slice(2)) : '';
|
||||
let lastEvaluatedKey = undefined;
|
||||
const now = Date.now() / 1000;
|
||||
while (true) {
|
||||
const params = {
|
||||
TableName: tableName,
|
||||
ExclusiveStartKey: lastEvaluatedKey,
|
||||
KeyConditionExpression: prefixSk ? `#pk = :pk AND begins_with(#sk, :sk)` : `#pk = :pk`,
|
||||
ExpressionAttributeNames: {
|
||||
'#pk': pk,
|
||||
...(prefixSk && { '#sk': sk })
|
||||
},
|
||||
ExpressionAttributeValues: {
|
||||
':pk': { S: prefixPk },
|
||||
...(prefixSk && { ':sk': { S: prefixSk } })
|
||||
}
|
||||
};
|
||||
|
||||
const result = await dynamo('Query', params);
|
||||
|
||||
for (const item of result.Items || []) {
|
||||
if (item[ttl] && item[ttl].N < now) {
|
||||
continue;
|
||||
}
|
||||
yield [[item[pk].S, item[sk].S], JSON.parse(item.value.S)];
|
||||
}
|
||||
|
||||
if (!result.LastEvaluatedKey) break;
|
||||
lastEvaluatedKey = result.LastEvaluatedKey;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
133
packages/auth/src/storage/memory.ts
Normal file
133
packages/auth/src/storage/memory.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Configure OpenAuth to use a simple in-memory store.
|
||||
*
|
||||
* :::caution
|
||||
* This is not meant to be used in production.
|
||||
* :::
|
||||
*
|
||||
* This is useful for testing and development. It's not meant to be used in production.
|
||||
*
|
||||
* ```ts
|
||||
* import { MemoryStorage } from "@openauthjs/openauth/storage/memory"
|
||||
*
|
||||
* const storage = MemoryStorage()
|
||||
*
|
||||
* export default issuer({
|
||||
* storage,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Optionally, you can persist the store to a file.
|
||||
*
|
||||
* ```ts
|
||||
* MemoryStorage({
|
||||
* persist: "./persist.json"
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import { joinKey, splitKey, StorageAdapter } from './storage.js';
|
||||
|
||||
/**
|
||||
* Configure the memory store.
|
||||
*/
|
||||
export interface MemoryStorageOptions {
|
||||
/**
|
||||
* Optionally, backup the store to a file. So it'll be persisted when the issuer restarts.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* persist: "./persist.json"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
persist?: string;
|
||||
}
|
||||
export function MemoryStorage(input?: MemoryStorageOptions): StorageAdapter {
|
||||
const store = [] as [string, { value: Record<string, any>; expiry?: number }][];
|
||||
|
||||
if (input?.persist) {
|
||||
if (existsSync(input.persist)) {
|
||||
const file = readFileSync(input?.persist);
|
||||
store.push(...JSON.parse(file.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!input?.persist) return;
|
||||
const file = JSON.stringify(store);
|
||||
await writeFile(input.persist, file);
|
||||
}
|
||||
|
||||
function search(key: string) {
|
||||
let left = 0;
|
||||
let right = store.length - 1;
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const comparison = key.localeCompare(store[mid][0]);
|
||||
|
||||
if (comparison === 0) {
|
||||
return { found: true, index: mid };
|
||||
} else if (comparison < 0) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
return { found: false, index: left };
|
||||
}
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const match = search(joinKey(key));
|
||||
if (!match.found) return undefined;
|
||||
const entry = store[match.index][1];
|
||||
if (entry.expiry && Date.now() >= entry.expiry) {
|
||||
store.splice(match.index, 1);
|
||||
await save();
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
},
|
||||
async set(key: string[], value: any, expiry?: Date) {
|
||||
const joined = joinKey(key);
|
||||
const match = search(joined);
|
||||
// Handle both Date objects and TTL numbers while maintaining Date type in signature
|
||||
const entry = [
|
||||
joined,
|
||||
{
|
||||
value,
|
||||
expiry: expiry ? expiry.getTime() : expiry
|
||||
}
|
||||
] as (typeof store)[number];
|
||||
if (!match.found) {
|
||||
store.splice(match.index, 0, entry);
|
||||
} else {
|
||||
store[match.index] = entry;
|
||||
}
|
||||
await save();
|
||||
},
|
||||
async remove(key: string[]) {
|
||||
const joined = joinKey(key);
|
||||
const match = search(joined);
|
||||
if (match.found) {
|
||||
store.splice(match.index, 1);
|
||||
await save();
|
||||
}
|
||||
},
|
||||
async *scan(prefix: string[]) {
|
||||
const now = Date.now();
|
||||
const prefixStr = joinKey(prefix);
|
||||
for (const [key, entry] of store) {
|
||||
if (!key.startsWith(prefixStr)) continue;
|
||||
if (entry.expiry && now >= entry.expiry) continue;
|
||||
yield [splitKey(key), entry.value];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
38
packages/auth/src/storage/storage.ts
Normal file
38
packages/auth/src/storage/storage.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface StorageAdapter {
|
||||
get(key: string[]): Promise<Record<string, any> | undefined>;
|
||||
remove(key: string[]): Promise<void>;
|
||||
set(key: string[], value: any, expiry?: Date): Promise<void>;
|
||||
scan(prefix: string[]): AsyncIterable<[string[], any]>;
|
||||
}
|
||||
|
||||
const SEPERATOR = String.fromCharCode(0x1f);
|
||||
|
||||
export function joinKey(key: string[]) {
|
||||
return key.join(SEPERATOR);
|
||||
}
|
||||
|
||||
export function splitKey(key: string) {
|
||||
return key.split(SEPERATOR);
|
||||
}
|
||||
|
||||
export namespace Storage {
|
||||
function encode(key: string[]) {
|
||||
return key.map((k) => k.replaceAll(SEPERATOR, ''));
|
||||
}
|
||||
export function get<T>(adapter: StorageAdapter, key: string[]) {
|
||||
return adapter.get(encode(key)) as Promise<T | null>;
|
||||
}
|
||||
|
||||
export function set(adapter: StorageAdapter, key: string[], value: any, ttl?: number) {
|
||||
const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined;
|
||||
return adapter.set(encode(key), value, expiry);
|
||||
}
|
||||
|
||||
export function remove(adapter: StorageAdapter, key: string[]) {
|
||||
return adapter.remove(encode(key));
|
||||
}
|
||||
|
||||
export function scan<T>(adapter: StorageAdapter, key: string[]): AsyncIterable<[string[], T]> {
|
||||
return adapter.scan(encode(key));
|
||||
}
|
||||
}
|
||||
129
packages/auth/src/subject.ts
Normal file
129
packages/auth/src/subject.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Subjects are what the access token generated at the end of the auth flow will map to. Under
|
||||
* the hood, the access token is a JWT that contains this data.
|
||||
*
|
||||
* #### Define subjects
|
||||
*
|
||||
* ```ts title="subjects.ts"
|
||||
* import { object, string } from "valibot"
|
||||
*
|
||||
* const subjects = createSubjects({
|
||||
* user: object({
|
||||
* userID: string()
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* We are using [valibot](https://github.com/fabian-hiller/valibot) here. You can use any
|
||||
* validation library that's following the
|
||||
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
||||
*
|
||||
* :::tip
|
||||
* You typically want to place subjects in its own file so it can be imported by all of your apps.
|
||||
* :::
|
||||
*
|
||||
* You can start with one subject. Later you can add more for different types of users.
|
||||
*
|
||||
* #### Set the subjects
|
||||
*
|
||||
* Then you can pass it to the `issuer`.
|
||||
*
|
||||
* ```ts title="issuer.ts"
|
||||
* import { subjects } from "./subjects"
|
||||
*
|
||||
* const app = issuer({
|
||||
* providers: { ... },
|
||||
* subjects,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* #### Add the subject payload
|
||||
*
|
||||
* When your user completes the flow, you can add the subject payload in the `success` callback.
|
||||
*
|
||||
* ```ts title="issuer.ts"
|
||||
* const app = issuer({
|
||||
* providers: { ... },
|
||||
* subjects,
|
||||
* async success(ctx, value) {
|
||||
* let userID
|
||||
* if (value.provider === "password") {
|
||||
* console.log(value.email)
|
||||
* userID = ... // lookup user or create them
|
||||
* }
|
||||
* return ctx.subject("user", {
|
||||
* userID
|
||||
* })
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Here we are looking up the userID from our database and adding it to the subject payload.
|
||||
*
|
||||
* :::caution
|
||||
* You should only store properties that won't change for the lifetime of the user.
|
||||
* :::
|
||||
*
|
||||
* Since these will be stored in the access token, you should avoid storing information
|
||||
* that'll change often. For example, if you store the user's username, you'll need to
|
||||
* revoke the access token when the user changes their username.
|
||||
*
|
||||
* #### Decode the subject
|
||||
*
|
||||
* Now when your user logs in, you can use the OpenAuth client to decode the subject. For
|
||||
* example, in our SSR app we can do the following.
|
||||
*
|
||||
* ```ts title="app/page.tsx"
|
||||
* import { subjects } from "../subjects"
|
||||
*
|
||||
* const verified = await client.verify(subjects, cookies.get("access_token")!)
|
||||
* console.log(verified.subject.properties.userID)
|
||||
* ```
|
||||
*
|
||||
* All this is typesafe based on the shape of the subjects you defined.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
import type { v1 } from '@standard-schema/spec';
|
||||
|
||||
import { Prettify } from './util.js';
|
||||
|
||||
/**
|
||||
* Subject schema is a map of types that are used to define the subjects.
|
||||
*/
|
||||
export type SubjectSchema = Record<string, v1.StandardSchema>;
|
||||
|
||||
/** @internal */
|
||||
export type SubjectPayload<T extends SubjectSchema> = Prettify<
|
||||
{
|
||||
[type in keyof T & string]: {
|
||||
type: type;
|
||||
properties: v1.InferOutput<T[type]>;
|
||||
};
|
||||
}[keyof T & string]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Create a subject schema.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const subjects = createSubjects({
|
||||
* user: object({
|
||||
* userID: string()
|
||||
* }),
|
||||
* admin: object({
|
||||
* workspaceID: string()
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* This is using [valibot](https://github.com/fabian-hiller/valibot) to define the shape of the
|
||||
* subjects. You can use any validation library that's following the
|
||||
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
||||
*/
|
||||
export function createSubjects<Schema extends SubjectSchema = {}>(types: Schema): Schema {
|
||||
return { ...types };
|
||||
}
|
||||
104
packages/auth/src/ui/base.tsx
Normal file
104
packages/auth/src/ui/base.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { PropsWithChildren } from 'hono/jsx';
|
||||
|
||||
import { getTheme } from './theme.js';
|
||||
|
||||
import css from './css.js';
|
||||
|
||||
export function Layout(
|
||||
props: PropsWithChildren<{
|
||||
size?: 'small';
|
||||
}>
|
||||
) {
|
||||
const theme = getTheme();
|
||||
function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') {
|
||||
if (!theme) return;
|
||||
if (!theme[key]) return;
|
||||
if (typeof theme[key] === 'string') return theme[key];
|
||||
|
||||
return theme[key][mode] as string | undefined;
|
||||
}
|
||||
|
||||
const radius = (() => {
|
||||
if (theme?.radius === 'none') return '0';
|
||||
if (theme?.radius === 'sm') return '1';
|
||||
if (theme?.radius === 'md') return '1.25';
|
||||
if (theme?.radius === 'lg') return '1.5';
|
||||
if (theme?.radius === 'full') return '1000000000001';
|
||||
return '1';
|
||||
})();
|
||||
|
||||
const hasLogo = get('logo', 'light') && get('logo', 'dark');
|
||||
|
||||
return (
|
||||
<html
|
||||
style={{
|
||||
'--color-background-light': get('background', 'light'),
|
||||
'--color-background-dark': get('background', 'dark'),
|
||||
'--color-primary-light': get('primary', 'light'),
|
||||
'--color-primary-dark': get('primary', 'dark'),
|
||||
'--font-family': theme?.font?.family,
|
||||
'--font-scale': theme?.font?.scale,
|
||||
'--border-radius': radius
|
||||
}}>
|
||||
<head>
|
||||
<title>{theme?.title || 'OpenAuthJS'}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{theme?.favicon ? (
|
||||
<link rel="icon" href={theme?.favicon} />
|
||||
) : (
|
||||
<>
|
||||
<link rel="icon" href="https://openauth.js.org/favicon.ico" sizes="48x48" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://openauth.js.org/favicon.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://openauth.js.org/favicon-dark.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="https://openauth.js.org/favicon.svg"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{theme?.css && <style dangerouslySetInnerHTML={{ __html: theme.css }} />}
|
||||
</head>
|
||||
<body>
|
||||
<div data-component="root">
|
||||
<div data-component="center" data-size={props.size}>
|
||||
{hasLogo ? (
|
||||
<>
|
||||
<img data-component="logo" src={get('logo', 'light')} data-mode="light" />
|
||||
<img data-component="logo" src={get('logo', 'dark')} data-mode="dark" />
|
||||
</>
|
||||
) : (
|
||||
ICON_OPENAUTH
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_OPENAUTH = (
|
||||
<svg
|
||||
data-component="logo-default"
|
||||
width="51"
|
||||
height="51"
|
||||
viewBox="0 0 51 51"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 50.2303V0.12854H50.1017V50.2303H0ZM3.08002 11.8326H11.7041V3.20856H3.08002V11.8326ZM14.8526 11.8326H23.4766V3.20856H14.8526V11.8326ZM26.5566 11.8326H35.1807V3.20856H26.5566V11.8326ZM38.3292 11.8326H47.0217V3.20856H38.3292V11.8326ZM3.08002 23.6052H11.7041V14.9811H3.08002V23.6052ZM14.8526 23.6052H23.4766V14.9811H14.8526V23.6052ZM26.5566 23.6052H35.1807V14.9811H26.5566V23.6052ZM38.3292 23.6052H47.0217V14.9811H38.3292V23.6052ZM3.08002 35.3092H11.7041V26.6852H3.08002V35.3092ZM14.8526 35.3092H23.4766V26.6852H14.8526V35.3092ZM26.5566 35.3092H35.1807V26.6852H26.5566V35.3092ZM38.3292 35.3092H47.0217V26.6852H38.3292V35.3092ZM3.08002 47.1502H11.7041V38.3893H3.08002V47.1502ZM14.8526 47.1502H23.4766V38.3893H14.8526V47.1502ZM26.5566 47.1502H35.1807V38.3893H26.5566V47.1502ZM38.3292 47.1502H47.0217V38.3893H38.3292V47.1502Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
198
packages/auth/src/ui/code.tsx
Normal file
198
packages/auth/src/ui/code.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Configure the UI that's used by the Code provider.
|
||||
*
|
||||
* ```ts {1,7-12}
|
||||
* import { CodeUI } from "@openauthjs/openauth/ui/code"
|
||||
* import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* code: CodeAdapter(
|
||||
* CodeUI({
|
||||
* copy: {
|
||||
* code_info: "We'll send a pin code to your email"
|
||||
* },
|
||||
* sendCode: (claims, code) => console.log(claims.email, code)
|
||||
* })
|
||||
* )
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import { UnknownStateError } from '../error.js';
|
||||
import { CodeProviderOptions } from '../provider/code.js';
|
||||
import { Layout } from './base.js';
|
||||
import { FormAlert } from './form.js';
|
||||
|
||||
const DEFAULT_COPY = {
|
||||
/**
|
||||
* Copy for the email input.
|
||||
*/
|
||||
email_placeholder: 'Email',
|
||||
/**
|
||||
* Error message when the email is invalid.
|
||||
*/
|
||||
email_invalid: 'Email address is not valid',
|
||||
/**
|
||||
* Copy for the continue button.
|
||||
*/
|
||||
button_continue: 'Continue',
|
||||
/**
|
||||
* Copy informing that the pin code will be emailed.
|
||||
*/
|
||||
code_info: "We'll send a pin code to your email.",
|
||||
/**
|
||||
* Copy for the pin code input.
|
||||
*/
|
||||
code_placeholder: 'Code',
|
||||
/**
|
||||
* Error message when the code is invalid.
|
||||
*/
|
||||
code_invalid: 'Invalid code',
|
||||
/**
|
||||
* Copy for when the code was sent.
|
||||
*/
|
||||
code_sent: 'Code sent to ',
|
||||
/**
|
||||
* Copy for when the code was resent.
|
||||
*/
|
||||
code_resent: 'Code resent to ',
|
||||
/**
|
||||
* Copy for the link to resend the code.
|
||||
*/
|
||||
code_didnt_get: "Didn't get code?",
|
||||
/**
|
||||
* Copy for the resend button.
|
||||
*/
|
||||
code_resend: 'Resend'
|
||||
};
|
||||
|
||||
export type CodeUICopy = typeof DEFAULT_COPY;
|
||||
|
||||
/**
|
||||
* Configure the password UI.
|
||||
*/
|
||||
export interface CodeUIOptions {
|
||||
/**
|
||||
* Callback to send the pin code to the user.
|
||||
*
|
||||
* The `claims` object contains the email or phone number of the user. You can send the code
|
||||
* using this.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* async (claims, code) => {
|
||||
* // Send the code via the claim
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
sendCode: (claims: Record<string, string>, code: string) => Promise<void>;
|
||||
/**
|
||||
* Custom copy for the UI.
|
||||
*/
|
||||
copy?: Partial<CodeUICopy>;
|
||||
/**
|
||||
* The mode to use for the input.
|
||||
* @default "email"
|
||||
*/
|
||||
mode?: 'email' | 'phone';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UI for the Code provider flow.
|
||||
* @param props - Configure the UI.
|
||||
*/
|
||||
export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
||||
const copy = {
|
||||
...DEFAULT_COPY,
|
||||
...props.copy
|
||||
};
|
||||
|
||||
const mode = props.mode ?? 'email';
|
||||
|
||||
return {
|
||||
sendCode: props.sendCode,
|
||||
length: 6,
|
||||
request: async (_req, state, _form, error): Promise<Response> => {
|
||||
if (state.type === 'start') {
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<form data-component="form" method="post">
|
||||
{error?.type === 'invalid_claim' && <FormAlert message={copy.email_invalid} />}
|
||||
<input type="hidden" name="action" value="request" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type={mode === 'email' ? 'email' : 'tel'}
|
||||
name={mode === 'email' ? 'email' : 'phone'}
|
||||
inputmode={mode === 'email' ? 'email' : 'numeric'}
|
||||
required
|
||||
placeholder={copy.email_placeholder}
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
</form>
|
||||
<p data-component="form-footer">{copy.code_info}</p>
|
||||
</Layout>
|
||||
);
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (state.type === 'code') {
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<form data-component="form" class="form" method="post">
|
||||
{error?.type === 'invalid_code' && <FormAlert message={copy.code_invalid} />}
|
||||
{state.type === 'code' && (
|
||||
<FormAlert
|
||||
message={(state.resend ? copy.code_resent : copy.code_sent) + state.claims.email}
|
||||
color="success"
|
||||
/>
|
||||
)}
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
type="text"
|
||||
name="code"
|
||||
required
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder={copy.code_placeholder}
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
{Object.entries(state.claims).map(([key, value]) => (
|
||||
<input key={key} type="hidden" name={key} value={value} className="hidden" />
|
||||
))}
|
||||
<input type="hidden" name="action" value="request" />
|
||||
<div data-component="form-footer">
|
||||
<span>
|
||||
{copy.code_didnt_get} <button data-component="link">{copy.code_resend}</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new UnknownStateError();
|
||||
}
|
||||
};
|
||||
}
|
||||
247
packages/auth/src/ui/css.ts
Normal file
247
packages/auth/src/ui/css.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
export default `:root {
|
||||
--color-background-dark: #0e0e11;
|
||||
--color-background-light: #ffffff;
|
||||
--color-primary-dark: #6772e5;
|
||||
--color-primary-light: #6772e5;
|
||||
|
||||
--color-background-success-dark: oklch(0.3 0.04 172);
|
||||
--color-background-success-light: oklch(from var(--color-background-success-dark) 0.83 c h);
|
||||
--color-success-dark: oklch(from var(--color-background-success-dark) 0.92 c h);
|
||||
--color-success-light: oklch(from var(--color-background-success-dark) 0.25 c h);
|
||||
|
||||
--color-background-error-dark: oklch(0.32 0.07 15);
|
||||
--color-background-error-light: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||
--color-error-dark: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||
--color-error-light: oklch(from var(--color-background-error-dark) 0.25 c h);
|
||||
|
||||
--border-radius: 0;
|
||||
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-primary: var(--color-primary-dark);
|
||||
|
||||
--color-background-success: var(--color-background-success-dark);
|
||||
--color-success: var(--color-success-dark);
|
||||
--color-background-error: var(--color-background-error-dark);
|
||||
--color-error: var(--color-error-dark);
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
--color-background: var(--color-background-light);
|
||||
--color-primary: var(--color-primary-light);
|
||||
|
||||
--color-background-success: var(--color-background-success-light);
|
||||
--color-success: var(--color-success-light);
|
||||
--color-background-error: var(--color-background-error-light);
|
||||
--color-error: var(--color-error-light);
|
||||
}
|
||||
|
||||
--color-high: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
--color-low: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * 1000), 1) 0 0);
|
||||
--lightness-high: color-mix(in oklch, var(--color-high) 0%, oklch(var(--color-high) 0 0));
|
||||
--lightness-low: color-mix(in oklch, var(--color-low) 0%, oklch(var(--color-low) 0 0));
|
||||
--font-family:
|
||||
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-scale: 1;
|
||||
|
||||
--font-size-xs: calc(0.75rem * var(--font-scale));
|
||||
--font-size-sm: calc(0.875rem * var(--font-scale));
|
||||
--font-size-md: calc(1rem * var(--font-scale));
|
||||
--font-size-lg: calc(1.125rem * var(--font-scale));
|
||||
--font-size-xl: calc(1.25rem * var(--font-scale));
|
||||
--font-size-2xl: calc(1.5rem * var(--font-scale));
|
||||
}
|
||||
|
||||
[data-component='root'] {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--color-background);
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
[data-component='center'] {
|
||||
width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
&[data-size='small'] {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='link'] {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-component='label'] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
[data-component='logo'] {
|
||||
margin: 0 auto;
|
||||
height: 2.5rem;
|
||||
width: auto;
|
||||
display: none;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
&[data-mode='light'] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&[data-mode='dark'] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='logo-default'] {
|
||||
margin: 0 auto;
|
||||
height: 2.5rem;
|
||||
width: auto;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: var(--color-high);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='input'] {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid transparent;
|
||||
--background: oklch(
|
||||
from var(--color-background) calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c
|
||||
h
|
||||
);
|
||||
background: var(--background);
|
||||
border-color: oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
font-size: var(--font-size-sm);
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.3, l + (-0.2 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.1), 0.7)) c h
|
||||
);
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: oklch(0.4 0.09 7.91);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='button'] {
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: oklch(from var(--color-primary) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
|
||||
&[data-color='ghost'] {
|
||||
background: transparent;
|
||||
color: var(--color-high);
|
||||
border: 1px solid
|
||||
oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
}
|
||||
|
||||
[data-slot='icon'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='form'] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-component='form-alert'] {
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
background: var(--color-background-error);
|
||||
color: var(--color-error);
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
&[data-color='success'] {
|
||||
background: var(--color-background-success);
|
||||
color: var(--color-success);
|
||||
|
||||
[data-slot='icon-success'] {
|
||||
display: block;
|
||||
}
|
||||
[data-slot='icon-danger'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:has([data-slot='message']:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot='icon-success'],
|
||||
[data-slot='icon-danger'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
[data-slot='icon-success'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component='form-footer'] {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:has(> :nth-child(2)) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}`;
|
||||
35
packages/auth/src/ui/form.tsx
Normal file
35
packages/auth/src/ui/form.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
export function FormAlert(props: { message?: string; color?: 'danger' | 'success' }) {
|
||||
return (
|
||||
<div data-component="form-alert" data-color={props.color}>
|
||||
<svg
|
||||
data-slot="icon-success"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
data-slot="icon-danger"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
<span data-slot="message">{props.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/auth/src/ui/icon.tsx
Normal file
86
packages/auth/src/ui/icon.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
export const ICON_GITHUB = (
|
||||
<svg
|
||||
viewBox="0 0 256 250"
|
||||
width="256"
|
||||
height="250"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid">
|
||||
<path d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICON_GOOGLE = (
|
||||
<svg
|
||||
width="256"
|
||||
height="262"
|
||||
viewBox="0 0 256 262"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid">
|
||||
<path
|
||||
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||
fill="#EB4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICON_EMAIL = (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICON_SLACK = (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.79948 0C7.47279 0.000978593 6.39909 1.07547 6.40007 2.39951C6.39909 3.72355 7.47377 4.79804 8.80046 4.79902H11.2009V2.40049C11.2018 1.07645 10.1271 0.00195719 8.79948 0ZM8.79948 6.4H2.40039C1.07371 6.40098 -0.000977873 7.47547 2.67973e-06 8.79951C-0.00195842 10.1235 1.07273 11.198 2.39941 11.2H8.79948C10.1262 11.199 11.2009 10.1245 11.1999 8.80049C11.2009 7.47547 10.1262 6.40098 8.79948 6.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M24.0007 8.79951C24.0016 7.47547 22.9269 6.40098 21.6003 6.4C20.2736 6.40098 19.1989 7.47547 19.1999 8.79951V11.2H21.6003C22.9269 11.199 24.0016 10.1245 24.0007 8.79951ZM17.6006 8.79951V2.39951C17.6016 1.07645 16.5279 0.00195719 15.2012 0C13.8745 0.000978593 12.7998 1.07547 12.8008 2.39951V8.79951C12.7988 10.1235 13.8735 11.198 15.2002 11.2C16.5269 11.199 17.6016 10.1245 17.6006 8.79951Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M15.1992 23.9998C16.5259 23.9988 17.6006 22.9243 17.5996 21.6003C17.6006 20.2763 16.5259 19.2018 15.1992 19.2008H12.7988V21.6003C12.7978 22.9234 13.8725 23.9978 15.1992 23.9998ZM15.1992 17.5988H21.5993C22.926 17.5978 24.0007 16.5234 23.9997 15.1993C24.0016 13.8753 22.927 12.8008 21.6003 12.7988H15.2002C13.8735 12.7998 12.7988 13.8743 12.7998 15.1983C12.7988 16.5234 13.8725 17.5978 15.1992 17.5988Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 15.1993C-0.000979882 16.5234 1.07371 17.5978 2.40039 17.5988C3.72708 17.5978 4.80177 16.5234 4.80079 15.1993V12.7998H2.40039C1.07371 12.8008 -0.000979882 13.8753 0 15.1993ZM6.40007 15.1993V21.5993C6.3981 22.9234 7.47279 23.9978 8.79948 23.9998C10.1262 23.9988 11.2009 22.9243 11.1999 21.6003V15.2013C11.2018 13.8772 10.1271 12.8027 8.80046 12.8008C7.47279 12.8008 6.39909 13.8753 6.40007 15.1993Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
390
packages/auth/src/ui/password.tsx
Normal file
390
packages/auth/src/ui/password.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Configure the UI that's used by the Password provider.
|
||||
*
|
||||
* ```ts {1,7-12}
|
||||
* import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||
* import { PasswordProvider } from "@openauthjs/openauth/provider/password"
|
||||
*
|
||||
* export default issuer({
|
||||
* providers: {
|
||||
* password: PasswordAdapter(
|
||||
* PasswordUI({
|
||||
* copy: {
|
||||
* error_email_taken: "This email is already taken."
|
||||
* },
|
||||
* sendCode: (email, code) => console.log(email, code)
|
||||
* })
|
||||
* )
|
||||
* },
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import {
|
||||
PasswordChangeError,
|
||||
PasswordConfig,
|
||||
PasswordLoginError,
|
||||
PasswordRegisterError
|
||||
} from '../provider/password.js';
|
||||
import { Layout } from './base.js';
|
||||
import './form.js';
|
||||
import { FormAlert } from './form.js';
|
||||
|
||||
const DEFAULT_COPY = {
|
||||
/**
|
||||
* Error message when email is already taken.
|
||||
*/
|
||||
error_email_taken: 'There is already an account with this email.',
|
||||
/**
|
||||
* Error message when the confirmation code is incorrect.
|
||||
*/
|
||||
error_invalid_code: 'Code is incorrect.',
|
||||
/**
|
||||
* Error message when the email is invalid.
|
||||
*/
|
||||
error_invalid_email: 'Email is not valid.',
|
||||
/**
|
||||
* Error message when the password is incorrect.
|
||||
*/
|
||||
error_invalid_password: 'Password is incorrect.',
|
||||
/**
|
||||
* Error message when the passwords do not match.
|
||||
*/
|
||||
error_password_mismatch: 'Passwords do not match.',
|
||||
/**
|
||||
* Error message when the user enters a password that fails validation.
|
||||
*/
|
||||
error_validation_error: 'Password does not meet requirements.',
|
||||
/**
|
||||
* Title of the register page.
|
||||
*/
|
||||
register_title: 'Welcome to the app',
|
||||
/**
|
||||
* Description of the register page.
|
||||
*/
|
||||
register_description: 'Sign in with your email',
|
||||
/**
|
||||
* Title of the login page.
|
||||
*/
|
||||
login_title: 'Welcome to the app',
|
||||
/**
|
||||
* Description of the login page.
|
||||
*/
|
||||
login_description: 'Sign in with your email',
|
||||
/**
|
||||
* Copy for the register button.
|
||||
*/
|
||||
register: 'Register',
|
||||
/**
|
||||
* Copy for the register link.
|
||||
*/
|
||||
register_prompt: "Don't have an account?",
|
||||
/**
|
||||
* Copy for the login link.
|
||||
*/
|
||||
login_prompt: 'Already have an account?',
|
||||
/**
|
||||
* Copy for the login button.
|
||||
*/
|
||||
login: 'Login',
|
||||
/**
|
||||
* Copy for the forgot password link.
|
||||
*/
|
||||
change_prompt: 'Forgot password?',
|
||||
/**
|
||||
* Copy for the resend code button.
|
||||
*/
|
||||
code_resend: 'Resend code',
|
||||
/**
|
||||
* Copy for the "Back to" link.
|
||||
*/
|
||||
code_return: 'Back to',
|
||||
/**
|
||||
* Copy for the logo.
|
||||
* @internal
|
||||
*/
|
||||
logo: 'A',
|
||||
/**
|
||||
* Copy for the email input.
|
||||
*/
|
||||
input_email: 'Email',
|
||||
/**
|
||||
* Copy for the password input.
|
||||
*/
|
||||
input_password: 'Password',
|
||||
/**
|
||||
* Copy for the code input.
|
||||
*/
|
||||
input_code: 'Code',
|
||||
/**
|
||||
* Copy for the repeat password input.
|
||||
*/
|
||||
input_repeat: 'Repeat password',
|
||||
/**
|
||||
* Copy for the continue button.
|
||||
*/
|
||||
button_continue: 'Continue'
|
||||
} satisfies {
|
||||
[key in `error_${
|
||||
| PasswordLoginError['type']
|
||||
| PasswordRegisterError['type']
|
||||
| PasswordChangeError['type']}`]: string;
|
||||
} & Record<string, string>;
|
||||
|
||||
type PasswordUICopy = typeof DEFAULT_COPY;
|
||||
|
||||
/**
|
||||
* Configure the password UI.
|
||||
*/
|
||||
export interface PasswordUIOptions extends Pick<PasswordConfig, 'sendCode' | 'validatePassword'> {
|
||||
/**
|
||||
* Custom copy for the UI.
|
||||
*/
|
||||
copy?: Partial<PasswordUICopy>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UI for the Password provider flow.
|
||||
* @param input - Configure the UI.
|
||||
*/
|
||||
export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
|
||||
const copy = {
|
||||
...DEFAULT_COPY,
|
||||
...input.copy
|
||||
};
|
||||
return {
|
||||
validatePassword: input.validatePassword,
|
||||
sendCode: input.sendCode,
|
||||
login: async (_req, form, error): Promise<Response> => {
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<form data-component="form" method="post">
|
||||
<FormAlert message={error?.type && copy?.[`error_${error.type}`]} />
|
||||
<input
|
||||
data-component="input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
autofocus={!error}
|
||||
value={form?.get('email')?.toString()}
|
||||
/>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={error?.type === 'invalid_password'}
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
<div data-component="form-footer">
|
||||
<span>
|
||||
{copy.register_prompt}{' '}
|
||||
<a data-component="link" href="register">
|
||||
{copy.register}
|
||||
</a>
|
||||
</span>
|
||||
<a data-component="link" href="change">
|
||||
{copy.change_prompt}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 401 : 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
},
|
||||
register: async (_req, state, form, error): Promise<Response> => {
|
||||
const emailError = ['invalid_email', 'email_taken'].includes(error?.type || '');
|
||||
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
|
||||
error?.type || ''
|
||||
);
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<form data-component="form" method="post">
|
||||
<FormAlert
|
||||
message={
|
||||
error?.type
|
||||
? error.type === 'validation_error'
|
||||
? (error.message ?? copy?.[`error_${error.type}`])
|
||||
: copy?.[`error_${error.type}`]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{state.type === 'start' && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={!error || emailError}
|
||||
type="email"
|
||||
name="email"
|
||||
value={!emailError ? form?.get('email')?.toString() : ''}
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={passwordError}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input
|
||||
data-component="input"
|
||||
type="password"
|
||||
name="repeat"
|
||||
required
|
||||
autofocus={passwordError}
|
||||
placeholder={copy.input_repeat}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
<div data-component="form-footer">
|
||||
<span>
|
||||
{copy.login_prompt}{' '}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.type === 'code' && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Layout>
|
||||
) as string;
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
},
|
||||
change: async (_req, state, form, error): Promise<Response> => {
|
||||
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
|
||||
error?.type || ''
|
||||
);
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<form data-component="form" method="post" replace>
|
||||
<FormAlert
|
||||
message={
|
||||
error?.type
|
||||
? error.type === 'validation_error'
|
||||
? (error.message ?? copy?.[`error_${error.type}`])
|
||||
: copy?.[`error_${error.type}`]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{state.type === 'start' && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={form?.get('email')?.toString()}
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{state.type === 'code' && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{state.type === 'update' && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="update" />
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input
|
||||
data-component="input"
|
||||
type="password"
|
||||
name="repeat"
|
||||
required
|
||||
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||
placeholder={copy.input_repeat}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button data-component="button">{copy.button_continue}</button>
|
||||
</form>
|
||||
{state.type === 'code' && (
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<input type="hidden" name="email" value={state.email} />
|
||||
{state.type === 'code' && (
|
||||
<div data-component="form-footer">
|
||||
<span>
|
||||
{copy.code_return}{' '}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login.toLowerCase()}
|
||||
</a>
|
||||
</span>
|
||||
<button data-component="link">{copy.code_resend}</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 400 : 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
201
packages/auth/src/ui/select.tsx
Normal file
201
packages/auth/src/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The UI that's displayed when loading the root page of the OpenAuth server. You can configure
|
||||
* which providers should be displayed in the select UI.
|
||||
*
|
||||
* ```ts
|
||||
* import { Select } from "@openauthjs/openauth/ui/select"
|
||||
*
|
||||
* export default issuer({
|
||||
* select: Select({
|
||||
* providers: {
|
||||
* github: {
|
||||
* hide: true
|
||||
* },
|
||||
* google: {
|
||||
* display: "Google"
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import { Layout } from './base.js';
|
||||
import { ICON_GITHUB, ICON_GOOGLE } from './icon.js';
|
||||
|
||||
export interface SelectProps {
|
||||
/**
|
||||
* An object with all the providers and their config; where the key is the provider name.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* github: {
|
||||
* hide: true
|
||||
* },
|
||||
* google: {
|
||||
* display: "Google"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* Whether to hide the provider from the select UI.
|
||||
* @default false
|
||||
*/
|
||||
hide?: boolean;
|
||||
/**
|
||||
* The display name of the provider.
|
||||
*/
|
||||
display?: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export function Select(props?: SelectProps) {
|
||||
return async (providers: Record<string, string>, _req: Request): Promise<Response> => {
|
||||
const jsx = (
|
||||
<Layout>
|
||||
<div data-component="form">
|
||||
{Object.entries(providers).map(([key, type]) => {
|
||||
const match = props?.providers?.[key];
|
||||
if (match?.hide) return;
|
||||
const icon = ICON[key];
|
||||
return (
|
||||
<a href={`/${key}/authorize`} data-component="button" data-color="ghost">
|
||||
{icon && <i data-slot="icon">{icon}</i>}
|
||||
Continue with {match?.display || DISPLAY[type] || type}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const DISPLAY: Record<string, string> = {
|
||||
twitch: 'Twitch',
|
||||
google: 'Google',
|
||||
github: 'GitHub',
|
||||
apple: 'Apple',
|
||||
x: 'X',
|
||||
facebook: 'Facebook',
|
||||
microsoft: 'Microsoft',
|
||||
slack: 'Slack'
|
||||
};
|
||||
|
||||
const ICON: Record<string, any> = {
|
||||
code: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 52 52"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
password: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
twitch: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"></path>
|
||||
</svg>
|
||||
),
|
||||
google: ICON_GOOGLE,
|
||||
github: ICON_GITHUB,
|
||||
apple: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 814 1000">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z "
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
x: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1227">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
microsoft: (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid">
|
||||
<path fill="#F1511B" d="M121.666 121.666H0V0h121.666z" />
|
||||
<path fill="#80CC28" d="M256 121.666H134.335V0H256z" />
|
||||
<path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z" />
|
||||
<path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z" />
|
||||
</svg>
|
||||
),
|
||||
facebook: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" fill="url(#a)">
|
||||
<defs>
|
||||
<linearGradient x1="50%" x2="50%" y1="97.078%" y2="0%" id="a">
|
||||
<stop offset="0%" stop-color="#0062E0" />
|
||||
<stop offset="100%" stop-color="#19AFFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M15 35.8C6.5 34.3 0 26.9 0 18 0 8.1 8.1 0 18 0s18 8.1 18 18c0 8.9-6.5 16.3-15 17.8l-1-.8h-4l-1 .8z" />
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m25 23 .8-5H21v-3.5c0-1.4.5-2.5 2.7-2.5H26V7.4c-1.3-.2-2.7-.4-4-.4-4.1 0-7 2.5-7 7v4h-4.5v5H15v12.7c1 .2 2 .3 3 .3s2-.1 3-.3V23h4z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
slack: (
|
||||
<svg
|
||||
role="img"
|
||||
enable-background="new 0 0 2447.6 2452.5"
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-rule="evenodd" fill-rule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="#36c5f0"
|
||||
/>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="#2eb67d"
|
||||
/>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="#ecb22e"
|
||||
/>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="#e01e5a"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
318
packages/auth/src/ui/theme.ts
Normal file
318
packages/auth/src/ui/theme.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Use one of the built-in themes.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* import { THEME_SST } from "@openauthjs/openauth/ui/theme"
|
||||
*
|
||||
* export default issuer({
|
||||
* theme: THEME_SST,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Or define your own.
|
||||
*
|
||||
* ```ts
|
||||
* import type { Theme } from "@openauthjs/openauth/ui/theme"
|
||||
*
|
||||
* const MY_THEME: Theme = {
|
||||
* title: "Acne",
|
||||
* radius: "none",
|
||||
* favicon: "https://www.example.com/favicon.svg",
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* export default issuer({
|
||||
* theme: MY_THEME,
|
||||
* // ...
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* A type to define values for light and dark mode.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* light: "#FFF",
|
||||
* dark: "#000"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface ColorScheme {
|
||||
/**
|
||||
* The value for dark mode.
|
||||
*/
|
||||
dark: string;
|
||||
/**
|
||||
* The value for light mode.
|
||||
*/
|
||||
light: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type to define your custom theme.
|
||||
*/
|
||||
export interface Theme {
|
||||
/**
|
||||
* The name of your app. Also used as the title of the page.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* title: "Acne"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* A URL to the favicon of your app.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* favicon: "https://www.example.com/favicon.svg"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
favicon?: string;
|
||||
/**
|
||||
* The border radius of the UI elements.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* radius: "none"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||
/**
|
||||
* The primary color of the theme.
|
||||
*
|
||||
* Takes a color or both light and dark colors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* primary: "#FF5E00"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
primary: string | ColorScheme;
|
||||
/**
|
||||
* The background color of the theme.
|
||||
*
|
||||
* Takes a color or both light and dark colors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* background: "#FFF"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
background?: string | ColorScheme;
|
||||
/**
|
||||
* A URL to the logo of your app.
|
||||
*
|
||||
* Takes a single image or both light and dark mode versions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* logo: "https://www.example.com/logo.svg"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
logo?: string | ColorScheme;
|
||||
/**
|
||||
* The font family and scale of the theme.
|
||||
*/
|
||||
font?: {
|
||||
/**
|
||||
* The font family of the theme.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* font: {
|
||||
* family: "Geist Mono, monospace"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* The font scale of the theme. Can be used to increase or decrease the font sizes across
|
||||
* the UI.
|
||||
*
|
||||
* @default "1"
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* font: {
|
||||
* scale: "1.25"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
scale?: string;
|
||||
};
|
||||
/**
|
||||
* Custom CSS that's added to the page in a `<style>` tag.
|
||||
*
|
||||
* This can be used to import custom fonts.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* css: `@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');`
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
css?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in default OpenAuth theme.
|
||||
*/
|
||||
export const THEME_OPENAUTH: Theme = {
|
||||
title: 'OpenAuth',
|
||||
radius: 'none',
|
||||
background: {
|
||||
dark: 'black',
|
||||
light: 'white'
|
||||
},
|
||||
primary: {
|
||||
dark: 'white',
|
||||
light: 'black'
|
||||
},
|
||||
font: {
|
||||
family: 'IBM Plex Sans, sans-serif'
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&display=swap');
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in theme based on [Terminal](https://terminal.shop).
|
||||
*/
|
||||
export const THEME_TERMINAL: Theme = {
|
||||
title: 'terminal',
|
||||
radius: 'none',
|
||||
favicon: 'https://www.terminal.shop/favicon.svg',
|
||||
logo: {
|
||||
dark: 'https://www.terminal.shop/images/logo-white.svg',
|
||||
light: 'https://www.terminal.shop/images/logo-black.svg'
|
||||
},
|
||||
primary: '#ff5e00',
|
||||
background: {
|
||||
dark: 'rgb(0, 0, 0)',
|
||||
light: 'rgb(255, 255, 255)'
|
||||
},
|
||||
font: {
|
||||
family: 'Geist Mono, monospace'
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in theme based on [SST](https://sst.dev).
|
||||
*/
|
||||
export const THEME_SST: Theme = {
|
||||
title: 'SST',
|
||||
favicon: 'https://sst.dev/favicon.svg',
|
||||
logo: {
|
||||
dark: 'https://sst.dev/favicon.svg',
|
||||
light: 'https://sst.dev/favicon.svg'
|
||||
},
|
||||
background: {
|
||||
dark: '#1a1a2d',
|
||||
light: 'rgb(255, 255, 255)'
|
||||
},
|
||||
primary: '#f3663f',
|
||||
font: {
|
||||
family: 'Rubik, sans-serif'
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in theme based on [Supabase](https://supabase.com).
|
||||
*/
|
||||
export const THEME_SUPABASE: Theme = {
|
||||
title: 'Supabase',
|
||||
logo: {
|
||||
dark: 'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-dark.svg&w=128&q=75',
|
||||
light:
|
||||
'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-light.svg&w=128&q=75'
|
||||
},
|
||||
background: {
|
||||
dark: '#171717',
|
||||
light: '#f8f8f8'
|
||||
},
|
||||
primary: {
|
||||
dark: '#006239',
|
||||
light: '#72e3ad'
|
||||
},
|
||||
font: {
|
||||
family: 'Varela Round, sans-serif'
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Varela+Round:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in theme based on [Vercel](https://vercel.com).
|
||||
*/
|
||||
export const THEME_VERCEL: Theme = {
|
||||
title: 'Vercel',
|
||||
logo: {
|
||||
dark: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-dark.e8c0a742.svg',
|
||||
light: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-light.700a8d26.svg'
|
||||
},
|
||||
background: {
|
||||
dark: 'black',
|
||||
light: 'white'
|
||||
},
|
||||
primary: {
|
||||
dark: 'white',
|
||||
light: 'black'
|
||||
},
|
||||
font: {
|
||||
family: 'Geist, sans-serif'
|
||||
},
|
||||
css: `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
`
|
||||
};
|
||||
|
||||
// i really don't wanna use async local storage for this so get over it
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function setTheme(value: Theme) {
|
||||
// @ts-ignore
|
||||
globalThis.OPENAUTH_THEME = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getTheme() {
|
||||
// @ts-ignore
|
||||
return globalThis.OPENAUTH_THEME || THEME_OPENAUTH;
|
||||
}
|
||||
56
packages/auth/src/util.ts
Normal file
56
packages/auth/src/util.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
|
||||
export function getRelativeUrl(ctx: Context, path: string) {
|
||||
const result = new URL(path, ctx.req.url);
|
||||
result.host = ctx.req.header('x-forwarded-host') || result.host;
|
||||
result.protocol = ctx.req.header('x-forwarded-proto') || result.protocol;
|
||||
result.port = ctx.req.header('x-forwarded-port') || result.port;
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
const twoPartTlds = [
|
||||
'co.uk',
|
||||
'co.jp',
|
||||
'co.kr',
|
||||
'co.nz',
|
||||
'co.za',
|
||||
'co.in',
|
||||
'com.au',
|
||||
'com.br',
|
||||
'com.cn',
|
||||
'com.mx',
|
||||
'com.tw',
|
||||
'net.au',
|
||||
'org.uk',
|
||||
'ne.jp',
|
||||
'ac.uk',
|
||||
'gov.uk',
|
||||
'edu.au',
|
||||
'gov.au'
|
||||
];
|
||||
|
||||
export function isDomainMatch(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
const partsA = a.split('.');
|
||||
const partsB = b.split('.');
|
||||
const hasTwoPartTld = twoPartTlds.some((tld) => a.endsWith('.' + tld) || b.endsWith('.' + tld));
|
||||
const numParts = hasTwoPartTld ? -3 : -2;
|
||||
const min = Math.min(partsA.length, partsB.length, numParts);
|
||||
const tailA = partsA.slice(min).join('.');
|
||||
const tailB = partsB.slice(min).join('.');
|
||||
return tailA === tailB;
|
||||
}
|
||||
|
||||
export function lazy<T>(fn: () => T): () => T {
|
||||
let value: T | undefined;
|
||||
return () => {
|
||||
if (value === undefined) {
|
||||
value = fn();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user