Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ BETTER_AUTH_URL=http://backend:3000 # Backend public base URL where /api/auth/*
# COOKIE_DOMAIN=.yourdomain.com # Set in prod when frontend and backend share a parent domain
# BETTER_AUTH_SECURE_COOKIES=false # Set to false when serving over plain http (defaults to true only for https:// BETTER_AUTH_URL); Secure cookies are dropped over http on non-localhost hosts

# Single sign-on via OIDC, e.g. Authentik (optional; see docs/self-hosting/configuration)
# Redirect URI to register in the IdP: <BETTER_AUTH_URL>/api/auth/oauth2/callback/authentik
# OIDC_ISSUER=https://auth.example.com/application/o/hive-pal/
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_BUTTON_LABEL=Sign in with Authentik
# OIDC_ALLOW_SIGNUP=true # create unknown users on first SSO login (users are matched by email)
# DISABLE_LOCAL_LOGIN=false # true = SSO only (password, magic link and passkeys disabled)

# API URLs
API_URL=http://backend:3000 # Internal API URL (used by frontend container)
# HiveScale was renamed to HiveHub. Prefer HIVEHUB_*; the legacy HIVESCALE_*
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ BETTER_AUTH_URL=http://localhost:3000
# COOKIE_DOMAIN=.hivepal.app
# BETTER_AUTH_SECURE_COOKIES=false

# Single sign-on via OIDC, e.g. Authentik (optional)
# Enabled when OIDC_ISSUER (or OIDC_DISCOVERY_URL), OIDC_CLIENT_ID and
# OIDC_CLIENT_SECRET are set. Users are matched by email address.
# Redirect URI to register in the IdP:
# <BETTER_AUTH_URL>/api/auth/oauth2/callback/<OIDC_PROVIDER_ID>
# OIDC_ISSUER=https://auth.example.com/application/o/hive-pal/
# OIDC_DISCOVERY_URL= # overrides <OIDC_ISSUER>/.well-known/openid-configuration
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_PROVIDER_ID=authentik
# OIDC_SCOPES="openid profile email"
# OIDC_BUTTON_LABEL="Sign in with Authentik"
# OIDC_ALLOW_SIGNUP=true # false = only existing users may sign in via SSO
# DISABLE_LOCAL_LOGIN=false # true = SSO only; ignored while OIDC isn't configured

# Email Configuration (Optional)
# The system will automatically select the first configured provider:
# 1. Resend (if RESEND_API_KEY is provided)
Expand Down
116 changes: 113 additions & 3 deletions apps/backend/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { magicLink, admin, customSession } from 'better-auth/plugins';
import {
magicLink,
admin,
customSession,
genericOAuth,
} from 'better-auth/plugins';
import { passkey } from '@better-auth/passkey';
import { createAuthMiddleware } from 'better-auth/api';
import {
APIError,
createAuthMiddleware,
getSessionFromCtx,
} from 'better-auth/api';
import {
hashPassword as scryptHash,
verifyPassword as scryptVerify,
Expand All @@ -12,6 +21,12 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';
import { authDeps } from './auth-deps';
import { UserLoginEvent } from '../events/auth.events';
import {
LOCAL_LOGIN_PATHS,
isLocalLoginDisabled,
loadOidcConfig,
} from './oidc-config';
import { secureOidcAccountLink } from './oidc-linking';

const prismaForAuth = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
Expand All @@ -38,6 +53,17 @@ const useSecureCookies =
? process.env.BETTER_AUTH_SECURE_COOKIES.toLowerCase() === 'true'
: (process.env.BETTER_AUTH_URL ?? '').startsWith('https://');

const oidc = loadOidcConfig();
const localLoginDisabled = isLocalLoginDisabled(process.env, oidc);
if (
!oidc.enabled &&
process.env.DISABLE_LOCAL_LOGIN?.toLowerCase() === 'true'
) {
console.warn(
'[auth] DISABLE_LOCAL_LOGIN is ignored because OIDC is not fully configured (OIDC_ISSUER/OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET).',
);
}

export const auth = betterAuth({
database: prismaAdapter(prismaForAuth, { provider: 'postgresql' }),
secret: process.env.BETTER_AUTH_SECRET,
Expand Down Expand Up @@ -82,6 +108,19 @@ export const auth = betterAuth({
},
},

account: {
accountLinking: {
enabled: true,
// The IdP is self-hosted and admin-controlled, so its email claim is
// trusted for matching even when it doesn't send email_verified=true.
trustedProviders: oidc.enabled ? [oidc.providerId] : [],
// Hive Pal has no email-verification step, so most local accounts are
// unverified. Linking them is allowed; secureOidcAccountLink() strips
// the local credentials of such accounts on an implicit link.
requireLocalEmailVerified: false,
},
},

session: {
expiresIn: 60 * 60 * 24 * 7,
cookieCache: { enabled: true, maxAge: 60 * 5 },
Expand All @@ -108,6 +147,17 @@ export const auth = betterAuth({
},

hooks: {
// Must stay async: Better Auth only honours errors from a rejected promise.
// eslint-disable-next-line @typescript-eslint/require-await
before: createAuthMiddleware(async (ctx) => {
if (localLoginDisabled && LOCAL_LOGIN_PATHS.has(ctx.path)) {
// Better Auth's APIError is how hooks reject a request.
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw new APIError('FORBIDDEN', {
message: 'Local login is disabled. Please sign in with SSO.',
});
}
}),
after: createAuthMiddleware(async (ctx) => {
// Lazy bcrypt → scrypt rehash on successful email sign-in
if (
Expand Down Expand Up @@ -158,6 +208,30 @@ export const auth = betterAuth({
},

databaseHooks: {
account: {
create: {
after: async (account, ctx) => {
if (!oidc.enabled) return;
await secureOidcAccountLink({
prisma: authDeps.prisma,
providerId: oidc.providerId,
account,
isLinkedBySignedInOwner: async () => {
// Manual linking (settings page) runs the OAuth callback with
// the owner's session cookie; an implicit link at sign-in
// has no session for this user.
if (!ctx) return false;
try {
const session = await getSessionFromCtx(ctx);
return session?.user.id === account.userId;
} catch {
return false;
}
},
});
},
},
},
user: {
create: {
after: async (user) => {
Expand Down Expand Up @@ -220,20 +294,56 @@ export const auth = betterAuth({
rpName: 'Hive Pal',
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
}),
...(oidc.enabled
? [
genericOAuth({
config: [
{
providerId: oidc.providerId,
discoveryUrl: oidc.discoveryUrl,
clientId: oidc.clientId,
clientSecret: oidc.clientSecret,
scopes: oidc.scopes,
pkce: true,
disableSignUp: !oidc.allowSignup,
mapProfileToUser: (profile) => ({
name:
profile.name || profile.preferred_username || profile.email,
}),
},
],
}),
]
: []),
admin({
defaultRole: 'USER',
adminRoles: ['ADMIN'],
}),
customSession(async ({ user, session }) => {
const dbUser = await authDeps.prisma.user.findUnique({
where: { id: user.id },
select: { passwordChangeRequired: true, role: true },
select: {
passwordChangeRequired: true,
role: true,
privacyPolicyConsent: true,
accounts: { select: { providerId: true } },
},
});
const providers = new Set(dbUser?.accounts.map((a) => a.providerId));
// Accounts created through SSO never went through the signup form, so
// they're asked for privacy-policy consent once after the first login.
const consentRequired =
oidc.enabled &&
!!dbUser &&
!dbUser.privacyPolicyConsent &&
providers.has(oidc.providerId) &&
!providers.has('credential');
return {
user: {
...user,
role: dbUser?.role ?? 'USER',
passwordChangeRequired: dbUser?.passwordChangeRequired ?? false,
consentRequired,
},
session,
};
Expand Down
95 changes: 95 additions & 0 deletions apps/backend/src/auth/oidc-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
buildDiscoveryUrl,
isLocalLoginDisabled,
loadOidcConfig,
} from './oidc-config';
import { buildEnvConfig } from '../env.controller';

const authentikEnv = {
OIDC_ISSUER: 'https://auth.example.com/application/o/hive-pal/',
OIDC_CLIENT_ID: 'client',
OIDC_CLIENT_SECRET: 'secret',
};

describe('loadOidcConfig', () => {
it('is disabled without configuration', () => {
expect(loadOidcConfig({}).enabled).toBe(false);
});

it('is disabled when the client secret is missing', () => {
expect(
loadOidcConfig({ ...authentikEnv, OIDC_CLIENT_SECRET: '' }).enabled,
).toBe(false);
});

it('derives the discovery URL from the Authentik issuer', () => {
const config = loadOidcConfig(authentikEnv);
expect(config.enabled).toBe(true);
expect(config.discoveryUrl).toBe(
'https://auth.example.com/application/o/hive-pal/.well-known/openid-configuration',
);
expect(config.providerId).toBe('authentik');
expect(config.scopes).toEqual(['openid', 'profile', 'email']);
expect(config.allowSignup).toBe(true);
});

it('prefers an explicit discovery URL and parses options', () => {
const config = loadOidcConfig({
...authentikEnv,
OIDC_DISCOVERY_URL: 'https://idp/.well-known/openid-configuration',
OIDC_SCOPES: 'openid,email',
OIDC_ALLOW_SIGNUP: 'false',
OIDC_BUTTON_LABEL: 'Login with Authentik',
});
expect(config.discoveryUrl).toBe(
'https://idp/.well-known/openid-configuration',
);
expect(config.scopes).toEqual(['openid', 'email']);
expect(config.allowSignup).toBe(false);
expect(config.buttonLabel).toBe('Login with Authentik');
});

it('strips trailing slashes when building the discovery URL', () => {
expect(buildDiscoveryUrl('https://idp/app//')).toBe(
'https://idp/app/.well-known/openid-configuration',
);
});
});

describe('isLocalLoginDisabled', () => {
it('is ignored while OIDC is not configured', () => {
expect(isLocalLoginDisabled({ DISABLE_LOCAL_LOGIN: 'true' })).toBe(false);
});

it('applies when OIDC is configured', () => {
expect(
isLocalLoginDisabled({ ...authentikEnv, DISABLE_LOCAL_LOGIN: 'true' }),
).toBe(true);
expect(isLocalLoginDisabled(authentikEnv)).toBe(false);
});
});

describe('buildEnvConfig SSO flags', () => {
it('exposes SSO settings without secrets', () => {
const config = buildEnvConfig({
...authentikEnv,
OIDC_ALLOW_SIGNUP: 'false',
});
expect(config).toMatchObject({
VITE_OIDC_ENABLED: 'true',
VITE_OIDC_PROVIDER_ID: 'authentik',
VITE_OIDC_BUTTON_LABEL: 'Sign in with SSO',
VITE_OIDC_ALLOW_SIGNUP: 'false',
VITE_LOCAL_LOGIN_ENABLED: 'true',
});
expect(JSON.stringify(config)).not.toContain('secret');
});

it('reports SSO as disabled by default', () => {
expect(buildEnvConfig({})).toMatchObject({
VITE_OIDC_ENABLED: 'false',
VITE_OIDC_ALLOW_SIGNUP: 'false',
VITE_LOCAL_LOGIN_ENABLED: 'true',
});
});
});
79 changes: 79 additions & 0 deletions apps/backend/src/auth/oidc-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// OIDC single sign-on (e.g. Authentik) configured entirely via environment
// variables. Users are matched to existing Hive Pal accounts by email.
//
// OIDC_ISSUER / OIDC_DISCOVERY_URL one of them is required to enable SSO.
// Authentik: OIDC_ISSUER=https://auth.example.com/application/o/<app-slug>/
// OIDC_CLIENT_ID, OIDC_CLIENT_SECRET required
// OIDC_PROVIDER_ID provider id used in the callback URL (default: authentik)
// → redirect URI: <BETTER_AUTH_URL>/api/auth/oauth2/callback/<id>
// OIDC_SCOPES space/comma separated (default: "openid profile email")
// OIDC_BUTTON_LABEL login button text (default: "Sign in with SSO")
// OIDC_ALLOW_SIGNUP create unknown users on first SSO login (default: true)
// DISABLE_LOCAL_LOGIN disable password, magic-link and passkey sign-in and
// email sign-up (default: false; only honoured while
// SSO is configured, to avoid locking everyone out)

export interface OidcConfig {
enabled: boolean;
providerId: string;
discoveryUrl: string;
clientId: string;
clientSecret: string;
scopes: string[];
buttonLabel: string;
allowSignup: boolean;
}

const parseBool = (value: string | undefined, fallback: boolean): boolean => {
if (value === undefined || value.trim() === '') return fallback;
return ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase());
};

export function buildDiscoveryUrl(issuer: string): string {
return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
}

export function loadOidcConfig(
env: NodeJS.ProcessEnv = process.env,
): OidcConfig {
const issuer = env.OIDC_ISSUER?.trim() ?? '';
const discoveryUrl =
env.OIDC_DISCOVERY_URL?.trim() || (issuer ? buildDiscoveryUrl(issuer) : '');
const clientId = env.OIDC_CLIENT_ID?.trim() ?? '';
const clientSecret = env.OIDC_CLIENT_SECRET?.trim() ?? '';
const scopes = (env.OIDC_SCOPES?.trim() || 'openid profile email')
.split(/[\s,]+/)
.filter(Boolean);

return {
enabled: !!(discoveryUrl && clientId && clientSecret),
providerId: env.OIDC_PROVIDER_ID?.trim() || 'authentik',
discoveryUrl,
clientId,
clientSecret,
scopes,
buttonLabel: env.OIDC_BUTTON_LABEL?.trim() || 'Sign in with SSO',
allowSignup: parseBool(env.OIDC_ALLOW_SIGNUP, true),
};
}

export function isLocalLoginDisabled(
env: NodeJS.ProcessEnv = process.env,
oidc: OidcConfig = loadOidcConfig(env),
): boolean {
return oidc.enabled && parseBool(env.DISABLE_LOCAL_LOGIN, false);
}

// Better Auth endpoint paths (route templates, as seen in `ctx.path`) that make
// up "local" login. Blocked when DISABLE_LOCAL_LOGIN is active.
export const LOCAL_LOGIN_PATHS = new Set([
'/sign-in/email',
'/sign-up/email',
'/sign-in/magic-link',
'/magic-link/verify',
'/request-password-reset',
'/reset-password',
'/reset-password/:token',
'/passkey/generate-authenticate-options',
'/passkey/verify-authentication',
]);
Loading
Loading