feat: Sync to OSS repo

This commit is contained in:
Wanjohi
2026-08-06 22:13:51 +03:00
parent 46d2a56180
commit 3faac3008f
144 changed files with 27561 additions and 0 deletions

View 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'
});
}

View 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
});
});
}
};
}

View 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];

View 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`
}
});
}

View 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'
}
});
}

View 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'
});
}

View 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'
}
});
}

View 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'
});
}

View 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';

View 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'
}
});
}

View 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);
}

View 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'
}
});
}

View 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'
});
}

View 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);
});
}
};
}

View 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
});
});
}
};
}

View 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'));
}
};
}

View 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 {}

View 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'
}
});
}

View 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'
}
});
}

View 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
});
});
}
};
}

View 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 });
});
}
};
}

View 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'
}
});
}

View 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
});
}

View 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'
}
});
}