Files
bewcloud/lib/models/oidc.ts
2025-06-06 05:47:06 +01:00

205 lines
5.5 KiB
TypeScript

import { decodeBase64Url } from 'std/encoding/base64url.ts';
import * as openIdClient from 'openid-client';
import 'std/dotenv/load.ts';
import { createSessionResponse, dataToText } from '/lib/auth.ts';
import { UserModel } from '/lib/models/user.ts';
import { generateRandomCode } from '/lib/utils/misc.ts';
import { AppConfig } from '/lib/config.ts';
import SimpleCache from '/lib/interfaces/simple-cache.ts';
const OIDC_CLIENT_ID = Deno.env.get('OIDC_CLIENT_ID') || '';
const OIDC_CLIENT_SECRET = Deno.env.get('OIDC_CLIENT_SECRET') || '';
interface OidcExtraState {
redirectTo?: string;
}
interface OidcJwtIdToken extends Record<string, string | undefined> {
email?: string;
name?: string;
sub?: string;
}
export class OidcModel {
static async getSignInUrl(
{
requestPermissions,
redirectUrlPath = '/oidc/callback',
extraState = {},
}: {
requestPermissions: string[];
redirectUrlPath?: string;
extraState?: OidcExtraState;
},
): Promise<string> {
const state = {
...extraState,
random: generateRandomCode(8),
};
const config = await AppConfig.getConfig();
const baseUrl = config.auth.baseUrl;
const oidcBaseUrl = config.auth.singleSignOnUrl;
const oidcOptions = oidcBaseUrl.startsWith('http://')
? { execute: [openIdClient.allowInsecureRequests] }
: undefined;
try {
const oidcConfig = await openIdClient.discovery(
new URL(oidcBaseUrl),
OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET,
undefined,
oidcOptions,
);
const redirectUrl = `${baseUrl}${redirectUrlPath}`;
const codeVerifier = openIdClient.randomPKCECodeVerifier();
const params = {
client_id: OIDC_CLIENT_ID,
redirect_uri: redirectUrl,
state: btoa(JSON.stringify(state)),
scope: requestPermissions.join(' '),
code_challenge: await openIdClient.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
};
const oidcStateCache = new SimpleCache(`oidc:state:${params.state}`);
await oidcStateCache.set(JSON.stringify({ state, codeVerifier }));
const oidcUrl = openIdClient.buildAuthorizationUrl(oidcConfig, params);
return oidcUrl.href;
} catch (error) {
console.log(`Failed to get OIDC sign in URL: ${error}`);
console.error(error);
return '';
}
}
private static decodeJwt(jwt: string): OidcJwtIdToken {
const jwtParts = jwt.split('.');
if (jwtParts.length !== 3) {
throw new Error('Malformed JWT');
}
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as OidcJwtIdToken;
}
private static parseState(state: string): OidcExtraState {
let stateParams: OidcExtraState = {};
try {
stateParams = JSON.parse(atob(state));
} catch (error) {
console.log(`Failed to parse OIDC state: ${error}`);
console.error(error);
}
return stateParams;
}
static async validateAndCreateSession(request: Request) {
const urlSearchParams = new URL(request.url).searchParams;
const state = urlSearchParams.get('state');
if (!state) {
throw new Error('Missing OIDC "state" parameter');
}
const oidcStateCache = new SimpleCache(`oidc:state:${state}`);
let expectedState: string;
let expectedCodeVerifier: string;
try {
const cacheValue = await oidcStateCache.get();
const { state, codeVerifier } = JSON.parse(cacheValue) as {
state: OidcExtraState;
codeVerifier: string;
};
expectedState = btoa(JSON.stringify(state));
expectedCodeVerifier = codeVerifier;
} catch (error) {
console.log(`Failed to verify/parse OIDC code: ${error}`);
console.error(error);
throw new Error('Invalid OIDC code');
}
const config = await AppConfig.getConfig();
const baseUrl = config.auth.baseUrl;
const oidcBaseUrl = config.auth.singleSignOnUrl;
const emailAttribute = config.auth.singleSignOnEmailAttribute;
const oidcOptions = oidcBaseUrl.startsWith('http://')
? { execute: [openIdClient.allowInsecureRequests] }
: undefined;
const oidcConfig = await openIdClient.discovery(
new URL(oidcBaseUrl),
OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET,
undefined,
oidcOptions,
);
const tokens = await openIdClient.authorizationCodeGrant(
oidcConfig,
new URL(`${baseUrl}?${urlSearchParams.toString()}`),
{
pkceCodeVerifier: expectedCodeVerifier,
expectedState,
},
);
const oidcParams = this.decodeJwt(tokens.id_token!);
const email = oidcParams[emailAttribute];
if (!email) {
throw new Error(`Missing user/${emailAttribute}`);
}
const isSignupAllowed = await AppConfig.isSignupAllowed();
const isThereAnAdmin = await UserModel.isThereAnAdmin();
// Confirm the user exists (or signup if allowed)
let user = await UserModel.getByEmail(email);
if (!user && (isSignupAllowed || !isThereAnAdmin)) {
// An empty password will always be impossible to login with
user = await UserModel.create(email, '');
}
if (!user) {
throw new Error('There was a problem signing up or logging in!');
}
let urlToRedirectTo = '/dashboard';
if (urlSearchParams.has('state')) {
const state = this.parseState(urlSearchParams.get('state')!);
if (state.redirectTo) {
urlToRedirectTo = state.redirectTo;
}
}
const response = await createSessionResponse(request, user, { urlToRedirectTo });
return {
response,
user,
};
}
}