diff --git a/.env.sample b/.env.sample index 0a7c831..2cbe1bf 100644 --- a/.env.sample +++ b/.env.sample @@ -13,4 +13,7 @@ PASSWORD_SALT="fake" MFA_KEY="fake" # optional, if you want to enable multi-factor authentication MFA_SALT="fake" # optional, if you want to enable multi-factor authentication +OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On) +OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On) + BREVO_API_KEY="fake" # optional, if you want to enable signup email verification diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 30b98b4..539b66b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,7 @@ -github: [BrunoBernardino] +github: [bewcloud, BrunoBernardino] custom: [ + 'https://donate.stripe.com/bIYeWBbw00Ape5iaFi', 'https://paypal.me/brunobernardino', 'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09', ] diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index 09a3141..b000f41 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -3,7 +3,7 @@ import { Config, PartialDeep } from './lib/types.ts'; /** Check the Config type for all the possible options and instructions. */ const config: PartialDeep = { auth: { - baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" + baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback") allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin enableEmailVerification: false, // If true, email verification will be required for signups (using Brevo) enableForeverSignup: true, // If true, all signups become active for 100 years diff --git a/deno.json b/deno.json index d8ffead..50aee6e 100644 --- a/deno.json +++ b/deno.json @@ -31,14 +31,15 @@ "preact/": "https://esm.sh/preact@10.23.2/", "@preact/signals": "https://esm.sh/*@preact/signals@1.3.0", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.0", - "tailwindcss": "npm:tailwindcss@3.4.15", - "tailwindcss/": "npm:/tailwindcss@3.4.15/", - "tailwindcss/plugin": "npm:/tailwindcss@3.4.15/plugin.js", + "tailwindcss": "npm:tailwindcss@3.4.17", + "tailwindcss/": "npm:/tailwindcss@3.4.17/", + "tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", "std/": "https://deno.land/std@0.224.0/", "$std/": "https://deno.land/std@0.224.0/", "chart.js": "https://esm.sh/chart.js@4.4.9/auto", "otpauth": "https://esm.sh/otpauth@9.4.0", "qrcode": "https://esm.sh/qrcode@1.5.4", + "openid-client": "https://esm.sh/openid-client@6.5.0", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0" diff --git a/fresh.gen.ts b/fresh.gen.ts index a151e32..7accc1d 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -53,6 +53,7 @@ import * as $news from './routes/news.tsx'; import * as $news_feeds from './routes/news/feeds.tsx'; import * as $notes from './routes/notes.tsx'; import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx'; +import * as $oidc_callback from './routes/oidc/callback.tsx'; import * as $photos from './routes/photos.tsx'; import * as $photos_thumbnail_fileName_ from './routes/photos/thumbnail/[fileName].tsx'; import * as $settings from './routes/settings.tsx'; @@ -124,6 +125,7 @@ const manifest = { './routes/news/feeds.tsx': $news_feeds, './routes/notes.tsx': $notes, './routes/notes/open/[fileName].tsx': $notes_open_fileName_, + './routes/oidc/callback.tsx': $oidc_callback, './routes/photos.tsx': $photos, './routes/photos/thumbnail/[fileName].tsx': $photos_thumbnail_fileName_, './routes/settings.tsx': $settings, diff --git a/lib/config.ts b/lib/config.ts index 003a134..0c26280 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,6 +14,10 @@ export class AppConfig { enableMultiFactor: false, allowedCookieDomains: [], skipCookieDomainSecurity: false, + enableSingleSignOn: false, + singleSignOnUrl: '', + singleSignOnEmailAttribute: 'email', + singleSignOnScopes: ['openid', 'email'], }, files: { rootPath: 'data-files', @@ -200,6 +204,12 @@ export class AppConfig { return this.config.auth.enableMultiFactor; } + static async isSingleSignOnEnabled(): Promise { + await this.loadConfig(); + + return this.config.auth.enableSingleSignOn; + } + static async getFilesRootPath(): Promise { await this.loadConfig(); diff --git a/lib/interfaces/simple-cache.ts b/lib/interfaces/simple-cache.ts new file mode 100644 index 0000000..693ee5a --- /dev/null +++ b/lib/interfaces/simple-cache.ts @@ -0,0 +1,69 @@ +const CACHE_NAME_PREFIX = 'bewcloud-v1-'; + +const CURRENT_CACHES: Set = new Set(); + +const FALLBACK_CACHE: Map = new Map(); + +export default class SimpleCache { + protected cacheName = `${CACHE_NAME_PREFIX}default`; + + constructor(cacheName = 'default') { + this.cacheName = `${CACHE_NAME_PREFIX}${cacheName}`; + } + + public async get() { + if (!CURRENT_CACHES.has(this.cacheName)) { + return ''; + } + + try { + const request = new Request(`https://fake.cache/${this.cacheName}`); + const cache = await caches.open(this.cacheName); + const response = await cache.match(request); + + if (response) { + return response.text(); + } + } catch (error) { + console.error(error); + + return FALLBACK_CACHE.get(this.cacheName) || ''; + } + + return ''; + } + + public async set(value: string) { + if (!CURRENT_CACHES.has(this.cacheName)) { + CURRENT_CACHES.add(this.cacheName); + } + + try { + await this.clear(); + + const request = new Request(`https://fake.cache/${this.cacheName}`); + const cache = await caches.open(this.cacheName); + const response = new Response(value, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + + await cache.put(request, response.clone()); + } catch (error) { + console.error(error); + + FALLBACK_CACHE.set(this.cacheName, value); + } + } + + public async clear() { + if (!CURRENT_CACHES.has(this.cacheName)) { + return null; + } + + try { + await caches.delete(this.cacheName); + } catch (error) { + console.error(error); + + FALLBACK_CACHE.delete(this.cacheName); + } + } +} diff --git a/lib/models/oidc.ts b/lib/models/oidc.ts new file mode 100644 index 0000000..cb2dccd --- /dev/null +++ b/lib/models/oidc.ts @@ -0,0 +1,203 @@ +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 { + email?: string; + name?: string; + sub?: string; +} + +export class OidcModel { + static async getSignInUrl( + { + requestPermissions, + redirectUrlPath = '/oidc/callback', + extraState = {}, + }: { + requestPermissions: string[]; + redirectUrlPath?: string; + extraState?: OidcExtraState; + }, + ): Promise { + 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 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(request.url), + { + 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, + }; + } +} diff --git a/lib/types.ts b/lib/types.ts index bca1340..6b89306 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -163,6 +163,14 @@ export interface Config { allowedCookieDomains: string[]; /** If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel. */ skipCookieDomainSecurity: boolean; + /** If true, single sign-on will be enabled */ + enableSingleSignOn: boolean; + /** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */ + singleSignOnUrl: string; + /** The attribute to prefer as email of the identity/single sign-on provider */ + singleSignOnEmailAttribute: string; + /** The scopes to request from the identity/single sign-on provider */ + singleSignOnScopes: string[]; }; files: { /** The root-relative root path for files, i.e. "data-files" */ diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts index a1d304d..0eb721f 100644 --- a/lib/utils/misc.ts +++ b/lib/utils/misc.ts @@ -58,6 +58,17 @@ export function generateRandomCode(length = 6) { return codeDigits.join(''); } +/** This generates a random string, including unicode characters. */ +export function generateRandomString(length = 10) { + const array = new Uint8Array(length * 2); + crypto.getRandomValues(array); + + return Array.from(array) + .map((byte) => String.fromCharCode(byte)) + .join('') + .slice(0, length); +} + export async function generateHash(value: string, algorithm: AlgorithmIdentifier) { const hashedValueData = await crypto.subtle.digest( algorithm, diff --git a/routes/login.tsx b/routes/login.tsx index 33e0b43..a952d61 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -9,6 +9,7 @@ import { FreshContextState } from '/lib/types.ts'; import { AppConfig } from '/lib/config.ts'; import { isMultiFactorAuthEnabledForUser } from '/lib/utils/multi-factor-auth.ts'; import { MultiFactorAuthModel } from '/lib/models/multi-factor-auth.ts'; +import { OidcModel } from '/lib/models/oidc.ts'; import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx'; interface Data { @@ -18,6 +19,8 @@ interface Data { formData?: FormData; isEmailVerificationEnabled: boolean; isMultiFactorAuthEnabled: boolean; + isSingleSignOnEnabled: boolean; + singleSignOnUrl?: string; helpEmail: string; } @@ -29,7 +32,13 @@ export const handler: Handlers = { const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled() && await UserModel.isThereAnAdmin(); - const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isSingleSignOnEnabled = await AppConfig.isSingleSignOnEnabled(); + const config = await AppConfig.getConfig(); + const helpEmail = config.visuals.helpEmail; + + const singleSignOnUrl = isSingleSignOnEnabled + ? (await OidcModel.getSignInUrl({ requestPermissions: config.auth.singleSignOnScopes })) + : undefined; const searchParams = new URL(request.url).searchParams; @@ -54,7 +63,9 @@ export const handler: Handlers = { formData, isEmailVerificationEnabled, isMultiFactorAuthEnabled, + isSingleSignOnEnabled, helpEmail, + singleSignOnUrl, }); }, async POST(request, context) { @@ -64,7 +75,13 @@ export const handler: Handlers = { const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled() && await UserModel.isThereAnAdmin(); - const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isSingleSignOnEnabled = await AppConfig.isSingleSignOnEnabled(); + const config = await AppConfig.getConfig(); + const helpEmail = config.visuals.helpEmail; + + const singleSignOnUrl = isSingleSignOnEnabled + ? (await OidcModel.getSignInUrl({ requestPermissions: config.auth.singleSignOnScopes })) + : undefined; const searchParams = new URL(request.url).searchParams; @@ -132,7 +149,9 @@ export const handler: Handlers = { formData, isEmailVerificationEnabled, isMultiFactorAuthEnabled, + isSingleSignOnEnabled, helpEmail, + singleSignOnUrl, }); } }, @@ -215,6 +234,27 @@ export default function Login({ data }: PageProps) { ) : null} + + {data?.isSingleSignOnEnabled && data?.singleSignOnUrl + ? ( +
+
+

or

+
+ +
+
+ + Login with SSO + +
+
+
+ ) + : null}

Need an account?

diff --git a/routes/oidc/callback.tsx b/routes/oidc/callback.tsx new file mode 100644 index 0000000..c4376eb --- /dev/null +++ b/routes/oidc/callback.tsx @@ -0,0 +1,62 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AppConfig } from '/lib/config.ts'; +import { OidcModel } from '/lib/models/oidc.ts'; + +interface Data { + error?: string; +} + +export const handler: Handlers = { + async GET(request, context) { + const isSingleSignOnEnabled = await AppConfig.isSingleSignOnEnabled(); + + if (context.state.user || !isSingleSignOnEnabled) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); + } + + let error = ''; + + try { + const { response } = await OidcModel.validateAndCreateSession(request); + + return response; + } catch (validationError) { + console.error(validationError); + error = (validationError as Error).message; + } + + return await context.render({ + error, + }); + }, +}; + +export default function OidcCallback({ data }: PageProps) { + return ( +
+
+

+ Login with SSO +

+ {data?.error + ? ( +
+

Failed to login!

+

{data?.error}

+
+ ) + : null} + +

Go back?

+

+ Go back to{' '} + + login + . +

+
+
+ ); +} diff --git a/routes/signup.tsx b/routes/signup.tsx index 8e0182d..d231977 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -7,6 +7,7 @@ import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { AppConfig } from '/lib/config.ts'; import { FreshContextState } from '/lib/types.ts'; +import { OidcModel } from '/lib/models/oidc.ts'; interface Data { error?: string; @@ -14,6 +15,9 @@ interface Data { email?: string; formData?: FormData; helpEmail: string; + isEmailVerificationEnabled: boolean; + isSingleSignOnEnabled: boolean; + singleSignOnUrl?: string; } export const handler: Handlers = { @@ -22,7 +26,14 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } - const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const isSingleSignOnEnabled = await AppConfig.isSingleSignOnEnabled(); + const config = await AppConfig.getConfig(); + const helpEmail = config.visuals.helpEmail; + + const singleSignOnUrl = isSingleSignOnEnabled + ? (await OidcModel.getSignInUrl({ requestPermissions: config.auth.singleSignOnScopes })) + : undefined; const searchParams = new URL(request.url).searchParams; @@ -32,14 +43,27 @@ export const handler: Handlers = { notice = `Your account and all its data has been deleted.`; } - return await context.render({ notice, helpEmail }); + return await context.render({ + notice, + helpEmail, + isEmailVerificationEnabled, + isSingleSignOnEnabled, + singleSignOnUrl, + }); }, async POST(request, context) { if (context.state.user) { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } - const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const isSingleSignOnEnabled = await AppConfig.isSingleSignOnEnabled(); + const config = await AppConfig.getConfig(); + const helpEmail = config.visuals.helpEmail; + + const singleSignOnUrl = isSingleSignOnEnabled + ? (await OidcModel.getSignInUrl({ requestPermissions: config.auth.singleSignOnScopes })) + : undefined; const formData = await request.clone().formData(); const email = getFormDataField(formData, 'email'); @@ -69,8 +93,6 @@ export const handler: Handlers = { const user = await UserModel.create(email, hashedPassword); - const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); - if (isEmailVerificationEnabled) { const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); @@ -83,20 +105,30 @@ export const handler: Handlers = { }); } catch (error) { console.error(error); - return await context.render({ error: (error as Error).toString(), email, formData, helpEmail }); + return await context.render({ + error: (error as Error).toString(), + email, + formData, + helpEmail, + isEmailVerificationEnabled, + isSingleSignOnEnabled, + singleSignOnUrl, + }); } }, }; -function formFields(email?: string) { +function formFields(data?: Data) { const fields: FormField[] = [ { name: 'email', label: 'Email', - description: `The email that will be used to login. A code will be sent to it.`, + description: data?.isEmailVerificationEnabled + ? `The email that will be used to login. A code will be sent to it.` + : `The email that will be used to login.`, type: 'email', placeholder: 'jane.doe@example.com', - value: email || '', + value: data?.email || '', required: true, }, { @@ -136,13 +168,36 @@ export default function Signup({ data }: PageProps) { ) : null} -
- {formFields(data?.email).map((field) => generateFieldHtml(field, data?.formData || new FormData()))} -
+ + {formFields(data).map((field) => generateFieldHtml(field, data?.formData || new FormData()))} +
+ {data?.isSingleSignOnEnabled && data?.singleSignOnUrl + ? ( +
+
+

or

+
+ +
+
+ + Signup with SSO + +
+
+
+ ) + : null} +

Already have an account?

If you already have an account,{' '} diff --git a/static/styles.css b/static/styles.css index 4cf13e4..1beefb1 100644 --- a/static/styles.css +++ b/static/styles.css @@ -24,15 +24,18 @@ form { } .button { - @apply inline-block rounded text-white bg-[#51a4fb] hover:bg-sky-400 hover:shadow-sm px-4 py-2; + @apply inline-block rounded text-white no-underline font-normal bg-[#51a4fb] hover:bg-sky-400 hover:shadow-sm + hover:no-underline px-4 py-2; } .button-secondary { - @apply inline-block rounded text-white bg-slate-600 hover:text-slate-900 hover:bg-slate-400 hover:shadow-sm px-4 py-2; + @apply inline-block rounded text-white no-underline font-normal bg-slate-600 hover:text-slate-900 hover:bg-slate-400 + hover:shadow-sm hover:no-underline px-4 py-2; } .button-danger { - @apply inline-block rounded text-slate-50 bg-red-600 hover:text-slate-900 hover:bg-red-400 hover:shadow-md px-4 py-2; + @apply inline-block rounded text-slate-50 no-underline font-normal bg-red-600 hover:text-slate-900 hover:bg-red-400 + hover:shadow-md hover:no-underline px-4 py-2; } .input-field {