Files
bknd/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts
T
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00

446 lines
13 KiB
TypeScript

import type { Authenticator, AuthAction } from "auth/authenticate/Authenticator";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
import { s, filterKeys } from "bknd/utils";
import { Exception } from "core/errors";
import { isDebug } from "core/env";
import { AuthStrategy } from "../Strategy";
type ConfiguredIssuers = keyof typeof issuers;
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const schemaProvided = s.object(
{
name: s.string({ enum: Object.keys(issuers) as ConfiguredIssuers[] }),
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oauth2" }),
client: s
.object({
client_id: s.string(),
client_secret: s.string(),
})
.strict(),
},
{ title: "OAuth" },
);
type ProvidedOAuthConfig = s.Static<typeof schemaProvided>;
export type CustomOAuthConfig = {
type: SupportedTypes;
name: string;
} & IssuerConfig & {
client: RequireKeys<
oauth.Client,
"client_id" | "client_secret" | "token_endpoint_auth_method"
>;
};
type OAuthConfig = ProvidedOAuthConfig | CustomOAuthConfig;
export type UserProfile = {
sub: string;
email: string;
[key: string]: any;
};
export type IssuerConfig<UserInfo = any> = {
type: SupportedTypes;
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
as: oauth.AuthorizationServer & {
scope_separator?: string;
};
profile: (
info: UserInfo,
config: Omit<IssuerConfig, "profile">,
tokenResponse: any,
) => Promise<UserProfile>;
};
export class OAuthCallbackException extends Exception {
override name = "OAuthCallbackException";
constructor(
public error: any,
public step: string,
) {
super("OAuthCallbackException on " + step);
}
}
export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
constructor(config: ProvidedOAuthConfig) {
super(config, "oauth", config.name, "external");
}
getSchema() {
return schemaProvided;
}
getIssuerConfig(): IssuerConfig {
return issuers[this.config.name];
}
async getConfig(): Promise<
IssuerConfig & {
client: {
client_id: string;
client_secret: string;
};
}
> {
const info = this.getIssuerConfig();
if (info.type === "oidc") {
const issuer = new URL(info.as.issuer);
const request = await oauth.discoveryRequest(issuer);
info.as = await oauth.processDiscoveryResponse(issuer, request);
}
return {
...info,
type: info.type,
client: {
...info.client,
...this.config.client,
},
};
}
async getCodeChallenge(as: oauth.AuthorizationServer, state: string, method: "S256" = "S256") {
const challenge_supported = as.code_challenge_methods_supported?.includes(method);
let challenge: string | undefined;
let challenge_method: string | undefined;
if (challenge_supported) {
challenge = await oauth.calculatePKCECodeChallenge(state);
challenge_method = method;
}
return { challenge_supported, challenge, challenge_method };
}
async request(options: { redirect_uri: string; state: string; scopes?: string[] }): Promise<{
url: string;
endpoint: string;
params: Record<string, string>;
}> {
const { client, as } = await this.getConfig();
const { challenge_supported, challenge, challenge_method } = await this.getCodeChallenge(
as,
options.state,
);
if (!as.authorization_endpoint) {
throw new Error("authorization_endpoint is not provided");
}
const scopes = options.scopes ?? as.scopes_supported;
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("No scopes provided");
}
if (scopes.every((scope) => !as.scopes_supported?.includes(scope))) {
throw new Error("Invalid scopes provided");
}
const endpoint = as.authorization_endpoint!;
const params: any = {
client_id: client.client_id,
redirect_uri: options.redirect_uri,
response_type: "code",
scope: scopes.join(as.scope_separator ?? " "),
};
if (challenge_supported) {
params.code_challenge = challenge;
params.code_challenge_method = challenge_method;
} else {
params.nonce = options.state;
}
return {
url: new URL(endpoint) + "?" + new URLSearchParams(params).toString(),
endpoint,
params,
};
}
private async oidc(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] },
) {
const config = await this.getConfig();
const { client, as, type } = config;
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state,
);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
const { challenge_supported, challenge } = await this.getCodeChallenge(as, options.state);
const expectedNonce = challenge_supported ? undefined : challenge;
const result = await oauth.processAuthorizationCodeOpenIDResponse(
as,
client,
response,
expectedNonce,
);
if (oauth.isOAuth2Error(result)) {
// @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
}
const claims = oauth.getValidatedIdTokenClaims(result);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
return await config.profile(resultUser, config, claims); // @todo: check claims
}
private async oauth2(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] },
) {
const config = await this.getConfig();
const { client, type, as, profile } = config;
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state,
);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
// slack does not return valid "token_type"...
const copy = response.clone();
let result: any = {};
try {
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
throw new Error(); // Handle OAuth 2.0 response body error
}
} catch (e) {
result = (await copy.json()) as any;
}
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json();
return await config.profile(user, config, result);
}
async callback(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] },
): Promise<UserProfile> {
const type = this.getIssuerConfig().type;
switch (type) {
case "oidc":
return await this.oidc(callbackParams, options);
case "oauth2":
return await this.oauth2(callbackParams, options);
default:
throw new Error("Unsupported type");
}
}
getController(auth: Authenticator): Hono<any> {
const hono = new Hono();
const secret = "secret";
const cookie_name = "_challenge";
type TState = {
state: string;
action: AuthAction;
redirect?: string;
mode: "token" | "cookie";
};
const setState = async (c: Context, config: TState): Promise<void> => {
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true,
httpOnly: true,
sameSite: "Lax",
maxAge: 60 * 5, // 5 minutes
});
};
const getState = async (c: Context): Promise<TState> => {
if (c.req.header("X-State-Challenge")) {
return {
state: c.req.header("X-State-Challenge"),
action: c.req.header("X-State-Action"),
mode: "token",
} as any;
}
const value = await getSignedCookie(c, secret, cookie_name);
try {
return JSON.parse(value as string);
} catch (e) {
throw new Error("Invalid state");
}
};
hono.get("/callback", async (c) => {
const url = new URL(c.req.url);
const params = new URLSearchParams(url.search);
const state = await getState(c);
// @todo: add config option to determine if state.action is allowed
const redirect_uri =
state.mode === "cookie"
? url.origin + url.pathname
: url.origin + url.pathname.replace("/callback", "/token");
const profile = await this.callback(params, {
redirect_uri,
state: state.state,
});
const safeProfile = {
email: profile.email,
strategy_value: profile.sub,
} as const;
const verify = async (user) => {
if (user.strategy_value !== profile.sub) {
throw new Exception("Invalid credentials");
}
};
const opts = {
redirect: state.redirect,
forceJsonResponse: state.mode !== "cookie",
} as const;
switch (state.action) {
case "login":
return auth.resolveLogin(c, this, safeProfile, verify, opts);
case "register":
return auth.resolveRegister(c, this, safeProfile, verify, opts);
default:
throw new Error("Invalid action");
}
});
hono.get("/token", async (c) => {
const url = new URL(c.req.url);
const params = new URLSearchParams(url.search);
return c.json({
code: params.get("code") ?? null,
});
});
hono.post("/:action", async (c) => {
const action = c.req.param("action") as AuthAction;
if (!["login", "register"].includes(action)) {
return c.notFound();
}
const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback";
const referer = new URL(c.req.header("Referer") ?? "/");
const state = oauth.generateRandomCodeVerifier();
const response = await this.request({
redirect_uri,
state,
});
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
return c.redirect(response.url);
});
hono.get("/:action", async (c) => {
const action = c.req.param("action") as AuthAction;
if (!["login", "register"].includes(action)) {
return c.notFound();
}
const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/token";
const state = oauth.generateRandomCodeVerifier();
const response = await this.request({
redirect_uri,
state,
});
if (isDebug()) {
return c.json({
url: response.url,
redirect_uri,
challenge: state,
action,
params: response.params,
});
}
return c.json({
url: response.url,
challenge: state,
action,
});
});
return hono;
}
override toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
...super.toJSON(secrets),
config: {
...config,
type: this.getIssuerConfig().type,
},
};
}
}