public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
import type { Hono } from "hono";
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
type Input = any; // workaround
// @todo: add schema to interface to ensure proper inference
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
getType: () => string;
getMode: () => "form" | "external";
getName: () => string;
toJSON: (secrets?: boolean) => any;
}
export type User = {
id: number;
email: string;
username: string;
password: string;
role: string;
};
export type ProfileExchange = {
email?: string;
username?: string;
sub?: string;
password?: string;
[key: string]: any;
};
export type SafeUser = Omit<User, "password">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (user: CreateUser) => Promise<User | undefined>;
}
export const jwtConfig = Type.Object(
{
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
secret: Type.String({ default: "secret" }),
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
expiresIn: Type.Optional(Type.String()),
issuer: Type.Optional(Type.String())
},
{
default: {},
additionalProperties: false
}
);
export const authenticatorConfig = Type.Object({
jwt: jwtConfig
});
type AuthConfig = Static<typeof authenticatorConfig>;
export type AuthAction = "login" | "register";
export type AuthUserResolver = (
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
) => Promise<SafeUser | undefined>;
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private _user: SafeUser | undefined;
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
this.strategies = strategies as Strategies;
this.config = parse(authenticatorConfig, config ?? {});
/*const secret = String(this.config.jwt.secret);
if (secret === "secret" || secret.length === 0) {
this.config.jwt.secret = randomString(64, true);
}*/
}
async resolve(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
) {
//console.log("resolve", { action, strategy: strategy.getName(), profile });
const user = await this.userResolver(action, strategy, identifier, profile);
if (user) {
return {
user,
token: await this.jwt(user)
};
}
throw new Error("User could not be resolved");
}
getStrategies(): Strategies {
return this.strategies;
}
isUserLoggedIn(): boolean {
return this._user !== undefined;
}
getUser() {
return this._user;
}
// @todo: determine what to do exactly
__setUserNull() {
this._user = undefined;
}
strategy<
StrategyName extends keyof Strategies,
Strat extends Strategy = Strategies[StrategyName]
>(strategy: StrategyName): Strat {
try {
return this.strategies[strategy] as unknown as Strat;
} catch (e) {
throw new Error(`Strategy "${String(strategy)}" not found`);
}
}
async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"];
for (const prop of prohibited) {
if (prop in user) {
throw new Error(`Property "${prop}" is prohibited`);
}
}
const jwt = new SignJWT(user)
.setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" })
.setIssuedAt();
if (this.config.jwt?.issuer) {
jwt.setIssuer(this.config.jwt.issuer);
}
if (this.config.jwt?.expiresIn) {
jwt.setExpirationTime(this.config.jwt.expiresIn);
}
return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? ""));
}
async verify(jwt: string): Promise<boolean> {
const options: JWTVerifyOptions = {
algorithms: [this.config.jwt?.alg ?? "HS256"]
};
if (this.config.jwt?.issuer) {
options.issuer = this.config.jwt.issuer;
}
if (this.config.jwt?.expiresIn) {
options.maxTokenAge = this.config.jwt.expiresIn;
}
try {
const { payload } = await jwtVerify<User>(
jwt,
new TextEncoder().encode(this.config.jwt?.secret ?? ""),
options
);
this._user = payload;
return true;
} catch (e) {
this._user = undefined;
//console.error(e);
}
return false;
}
toJSON(secrets?: boolean) {
return {
...this.config,
jwt: secrets ? this.config.jwt : undefined,
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
};
}
}

View File

@@ -0,0 +1,98 @@
import type { Authenticator, Strategy } from "auth";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { Hono } from "hono";
type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
const schema = Type.Object({
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" })
});
export type PasswordStrategyOptions = Static<typeof schema>;
/*export type PasswordStrategyOptions2 = {
hashing?: "plain" | "bcrypt" | "sha256";
};*/
export class PasswordStrategy implements Strategy {
private options: PasswordStrategyOptions;
constructor(options: Partial<PasswordStrategyOptions> = {}) {
this.options = parse(schema, options);
}
async hash(password: string) {
switch (this.options.hashing) {
case "sha256":
return hash.sha256(password);
default:
return password;
}
}
async login(input: LoginSchema) {
if (!("email" in input) || !("password" in input)) {
throw new Error("Invalid input: Email and password must be provided");
}
const hashedPassword = await this.hash(input.password);
return { ...input, password: hashedPassword };
}
async register(input: RegisterSchema) {
if (!input.email || !input.password) {
throw new Error("Invalid input: Email and password must be provided");
}
return {
...input,
password: await this.hash(input.password)
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
return hono
.post("/login", async (c) => {
const body = (await c.req.json()) ?? {};
const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload);
return c.json(data);
})
.post("/register", async (c) => {
const body = (await c.req.json()) ?? {};
const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload);
return c.json(data);
});
}
getSchema() {
return schema;
}
getType() {
return "password";
}
getMode() {
return "form" as const;
}
getName() {
return "password" as const;
}
toJSON(secrets?: boolean) {
return {
type: this.getType(),
config: secrets ? this.options : undefined
};
}
}

View File

@@ -0,0 +1,13 @@
import { CustomOAuthStrategy } from "auth/authenticate/strategies/oauth/CustomOAuthStrategy";
import { PasswordStrategy, type PasswordStrategyOptions } from "./PasswordStrategy";
import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
export * as issuers from "./oauth/issuers";
export {
PasswordStrategy,
type PasswordStrategyOptions,
OAuthStrategy,
OAuthCallbackException,
CustomOAuthStrategy
};

View File

@@ -0,0 +1,77 @@
import { type Static, StringEnum, Type } from "core/utils";
import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy";
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = Type.Object(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"])
},
{
additionalProperties: false
}
),
as: Type.Object(
{
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
scope_separator: Type.Optional(Type.String({ default: " " })),
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString)
},
{
additionalProperties: false
}
)
// @todo: profile mapping
},
{ title: "Custom OAuth", additionalProperties: false }
);
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
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 CustomOAuthStrategy extends OAuthStrategy {
override getIssuerConfig(): IssuerConfig {
return { ...this.config, profile: async (info) => info } as any;
}
// @ts-ignore
override getSchema() {
return oauthSchemaCustom;
}
override getType() {
return "custom_oauth";
}
}

View File

@@ -0,0 +1,431 @@
import type { AuthAction, Authenticator, Strategy } from "auth";
import { Exception } from "core";
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
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 = Type.Object(
{
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String()
},
{
additionalProperties: false
}
)
},
{ title: "OAuth" }
);
type ProvidedOAuthConfig = 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 implements Strategy {
constructor(private _config: OAuthConfig) {}
get config() {
return this._config;
}
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;
//console.log("config", config);
//console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState
);
if (oauth.isOAuth2Error(parameters)) {
//console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
/*console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);*/
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state
);
//console.log("callback.response", response);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @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)) {
//console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
}
//console.log("callback.result", result);
const claims = oauth.getValidatedIdTokenClaims(result);
//console.log("callback.IDTokenClaims", claims);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
//console.log("callback.resultUser", resultUser);
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;
console.log("config", { client, as, type });
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState
);
if (oauth.isOAuth2Error(parameters)) {
console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2)
);
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state
);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @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)) {
console.log("error", result);
throw new Error(); // Handle OAuth 2.0 response body error
}
} catch (e) {
result = (await copy.json()) as any;
console.log("failed", result);
}
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json();
console.log("res2", res2, user);
console.log("result", result);
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;
console.log("type", 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";
const setState = async (
c: Context,
config: { state: string; action: AuthAction; redirect?: string }
): 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<{ state: string; action: AuthAction; redirect?: string }> => {
const state = await getSignedCookie(c, secret, cookie_name);
try {
return JSON.parse(state 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);
console.log("url", url);
const profile = await this.callback(params, {
redirect_uri: url.origin + url.pathname,
state: state.state
});
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
console.log("******** RESOLVED ********", { user, token });
if (state.redirect) {
console.log("redirect to", state.redirect + "?token=" + token);
return c.redirect(state.redirect + "?token=" + token);
}
return c.json({ user, token });
});
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 + "/callback";
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
const state = await oauth.generateRandomCodeVerifier();
const response = await this.request({
redirect_uri,
state
});
//console.log("_state", state);
await setState(c, { state, action, redirect: q_redirect });
if (c.req.header("Accept") === "application/json") {
return c.json({
url: response.url,
redirect_uri,
challenge: state,
params: response.params
});
}
//return c.text(response.url);
console.log("--redirecting to", response.url);
return c.redirect(response.url);
});
return hono;
}
getType() {
return "oauth";
}
getMode() {
return "external" as const;
}
getName() {
return this.config.name;
}
getSchema() {
return schemaProvided;
}
toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
type: this.getType(),
config: {
type: this.getIssuerConfig().type,
...config
}
};
}
}

View File

@@ -0,0 +1,63 @@
import type { IssuerConfig } from "../OAuthStrategy";
type GithubUserInfo = {
id: number;
sub: string;
name: string;
email: null;
avatar_url: string;
};
type GithubUserEmailResponse = {
email: string;
primary: boolean;
verified: boolean;
visibility: string;
}[];
export const github: IssuerConfig<GithubUserInfo> = {
type: "oauth2",
client: {
token_endpoint_auth_method: "client_secret_basic",
},
as: {
code_challenge_methods_supported: ["S256"],
issuer: "https://github.com",
scopes_supported: ["read:user", "user:email"],
scope_separator: " ",
authorization_endpoint: "https://github.com/login/oauth/authorize",
token_endpoint: "https://github.com/login/oauth/access_token",
userinfo_endpoint: "https://api.github.com/user",
},
profile: async (
info: GithubUserInfo,
config: Omit<IssuerConfig, "profile">,
tokenResponse: any,
) => {
console.log("github info", info, config, tokenResponse);
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
"User-Agent": "bknd", // this is mandatory... *smh*
Accept: "application/json",
Authorization: `Bearer ${tokenResponse.access_token}`,
},
});
const data = (await res.json()) as GithubUserEmailResponse;
console.log("data", data);
const email = data.find((e: any) => e.primary)?.email;
if (!email) {
throw new Error("No primary email found");
}
return {
...info,
sub: String(info.id),
email: email,
};
} catch (e) {
throw new Error("Couldn't retrive github email");
}
},
};

View File

@@ -0,0 +1,29 @@
import type { IssuerConfig } from "../OAuthStrategy";
type GoogleUserInfo = {
sub: string;
name: string;
given_name: string;
family_name: string;
picture: string;
email: string;
email_verified: boolean;
locale: string;
};
export const google: IssuerConfig<GoogleUserInfo> = {
type: "oidc",
client: {
token_endpoint_auth_method: "client_secret_basic",
},
as: {
issuer: "https://accounts.google.com",
},
profile: async (info) => {
return {
...info,
sub: info.sub,
email: info.email,
};
},
};

View File

@@ -0,0 +1,2 @@
export { google } from "./google";
export { github } from "./github";