mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 09:45:21 +03:00
feat: Sync to OSS repo
This commit is contained in:
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'
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user