From 6303a25c57d24e6ed1359c4245ed58994cd78ebb Mon Sep 17 00:00:00 2001 From: Aaron Jarrett Date: Tue, 5 May 2026 03:47:49 +0000 Subject: [PATCH] feat: add native Authentik OIDC SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OidcController: authorization-code flow via jumbojett/openid-connect-php - OidcUserResolver: username mapping, case-insensitive admin break-glass guard, auto-provisioning — unit tested (8 assertions) - EloquentOidcUserRepo: find-or-create Heimdall user from OIDC claims - LoginController: show login page directly; Authentik button at top, local admin form below — no magic URL required - UserController: /userselect redirects to dash when OIDC active + authenticated - layouts/app.blade.php: hide Switch User button when OIDC active - config/services.php: oidc config block (all values from env) - routes/web.php: GET /auth/oidc/login and /auth/oidc/callback - Dockerfile: layer overlay + composer require + php84-sodium onto upstream image - AUTHENTIK_OIDC.md: setup guide and env var reference Tested against Authentik 2026.2.2, PHP 8.4 (Alpine), Laravel 11.45. Upstream pinned to v2.7.6 / lscr.io tag v2.7.6-ls341. --- AUTHENTIK_OIDC.md | 62 +++++++++ Dockerfile | 38 ++++++ app/Http/Controllers/Auth/LoginController.php | 7 +- app/Http/Controllers/Auth/OidcController.php | 111 ++++++++++++++++ app/Http/Controllers/UserController.php | 7 +- app/Services/EloquentOidcUserRepo.php | 27 ++++ app/Services/OidcUserRepoContract.php | 9 ++ app/Services/OidcUserResolver.php | 37 ++++++ config/services.php | 13 ++ resources/views/auth/login.blade.php | 14 ++- resources/views/layouts/app.blade.php | 2 + routes/web.php | 3 + tests/Unit/OidcUserResolverTest.php | 118 ++++++++++++++++++ 13 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 AUTHENTIK_OIDC.md create mode 100644 Dockerfile create mode 100644 app/Http/Controllers/Auth/OidcController.php create mode 100644 app/Services/EloquentOidcUserRepo.php create mode 100644 app/Services/OidcUserRepoContract.php create mode 100644 app/Services/OidcUserResolver.php create mode 100644 tests/Unit/OidcUserResolverTest.php diff --git a/AUTHENTIK_OIDC.md b/AUTHENTIK_OIDC.md new file mode 100644 index 000000000..0b7aa0dd0 --- /dev/null +++ b/AUTHENTIK_OIDC.md @@ -0,0 +1,62 @@ +# Authentik OIDC SSO for Heimdall + +This branch adds native OpenID Connect (OIDC) support to Heimdall, letting each user sign in once via Authentik (or any OIDC-compliant provider) and land on their own dashboard — no second login required. + +## How it works + +- Visiting `/login` shows a prominent **"Sign in with Authentik"** button above the standard password form. +- Clicking the button initiates the OIDC authorization-code flow against your provider. +- On callback, the `preferred_username` claim is mapped (optionally via `OIDC_USERNAME_MAP`) to a Heimdall username. The user is looked up or auto-provisioned, then logged in. +- The Heimdall `admin` account is hard-blocked from OIDC (`OIDC_ADMIN_BREAKGLASS_USERNAME`). Admin logs in via the password form on the same page — no special URL required. +- The "Switch User" panel and `/userselect` route are hidden/bypassed when OIDC is active, so each person sees only their own dashboard. + +## Authentik provider setup + +1. In Authentik Admin → Applications → Providers → Create: + - **Type:** OAuth2/OpenID Provider + - **Client type:** Confidential + - **Redirect URI:** `https:///auth/oidc/callback` + - **Subject mode:** Based on the User's username + - **Scopes:** `openid`, `email`, `profile` +2. Bind the provider to your Heimdall application. +3. Note the **Client ID**, **Client Secret**, and **Issuer URL** from the discovery document at `https:///application/o//.well-known/openid-configuration`. + +## Docker image + +```bash +docker build --build-arg UPSTREAM_TAG=v2.7.6-ls341 -t heimdall-sso . +docker run -d --name heimdall \ + -e OIDC_ENABLED=true \ + -e OIDC_ISSUER=https:///application/o// \ + -e OIDC_CLIENT_ID= \ + -e OIDC_CLIENT_SECRET= \ + -e OIDC_REDIRECT_URI=https:///auth/oidc/callback \ + -p 80:80 \ + -v /path/to/config:/config \ + heimdall-sso +``` + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `OIDC_ENABLED` | `false` | Master switch. `false` = stock Heimdall behaviour. | +| `OIDC_ISSUER` | — | OIDC issuer URL (from discovery doc `issuer` field). | +| `OIDC_CLIENT_ID` | — | OAuth2 client ID from your provider. | +| `OIDC_CLIENT_SECRET` | — | OAuth2 client secret from your provider. | +| `OIDC_REDIRECT_URI` | — | Must match the redirect URI registered in the provider. | +| `OIDC_AUTO_PROVISION` | `true` | Create a Heimdall user on first login if one doesn't exist. | +| `OIDC_ADMIN_BREAKGLASS_USERNAME` | `admin` | Heimdall username that is never authenticated via OIDC. | +| `OIDC_USERNAME_MAP` | `""` | Comma-separated `src:dst` pairs, e.g. `akadmin:aaronckj,other:alias`. Applied before the break-glass check. | +| `OIDC_SCOPES` | `openid,email,profile` | Comma-separated OIDC scopes to request. | + +## Break-glass admin login + +Visit `/login`. The password form below the Authentik button accepts the local `admin` credentials. No special URL needed. + +## Notes + +- Tested against Authentik 2026.2.2, PHP 8.4, Laravel 11.45. +- Uses [`jumbojett/openid-connect-php`](https://github.com/jumbojett/OpenID-Connect-PHP) for the OIDC client. Run `composer require jumbojett/openid-connect-php:^1.0` after installing Heimdall's dependencies, or use the provided Dockerfile which handles this automatically. +- jumbojett uses PHP's native `$_SESSION` for state/nonce storage. Heimdall's `file` session driver coexists with it correctly, but verify your session configuration if you switch drivers. +- The `admin` break-glass guard is case-insensitive and applies after username mapping, so mapping an external user to `admin` is also blocked. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..bf73fff09 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Pinned to the exact upstream tag the existing heimdall-1 container runs. +# linuxserver/heimdall stages the app at /app/www-tmp at build time; the s6 init +# copies /app/www-tmp -> /app/www at container startup. Our overlay therefore +# targets /app/www-tmp. +ARG UPSTREAM_TAG=v2.7.6-ls341 +FROM lscr.io/linuxserver/heimdall:${UPSTREAM_TAG} + +USER root + +# jumbojett/openid-connect-php needs ext-sodium (token crypto); not in upstream image. +RUN apk add --no-cache php84-sodium + +# linuxserver/heimdall doesn't ship composer; install it so we can pull in jumbojett. +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +WORKDIR /app/www-tmp + +# Add the OIDC client into Heimdall's vendor tree. +# --no-scripts: upstream's barryvdh/ide-helper post-install hook needs an .env at +# build time and we don't have one (it's created by the s6 init at runtime). +# Don't strip dev deps: AppServiceProvider conditionally registers IdeHelperServiceProvider +# when APP_ENV=local (Heimdall's default), so the dev package must remain present at runtime. +RUN composer require jumbojett/openid-connect-php:^1.0 --no-interaction --no-progress --no-scripts + +# Layer our overlay files on top of the stock app. +COPY overlay/app/ /app/www-tmp/app/ +COPY overlay/config/services.php /app/www-tmp/config/services.php +COPY overlay/routes/web.php /app/www-tmp/routes/web.php +COPY overlay/resources/views/auth/login.blade.php /app/www-tmp/resources/views/auth/login.blade.php +COPY overlay/resources/views/layouts/app.blade.php /app/www-tmp/resources/views/layouts/app.blade.php + +# Clear any stale bootstrap caches so our config/routes changes take effect. +RUN rm -f /app/www-tmp/bootstrap/cache/config.php \ + /app/www-tmp/bootstrap/cache/routes-v7.php \ + /app/www-tmp/bootstrap/cache/services.php + +# Restore ownership for the runtime user used by linuxserver init. +RUN chown -R abc:abc /app/www-tmp diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7b5be7651..b9a388c46 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -123,10 +123,13 @@ public function autologin($uuid): RedirectResponse /** * Show the application's login form. * - * @return Application|Factory|View + * @return Application|Factory|View|RedirectResponse */ - public function showLoginForm(): \Illuminate\View\View + public function showLoginForm(Request $request): \Illuminate\View\View { + // Always show the login view. When OIDC is active the view renders a prominent + // "Sign in with Authentik" button at the top; the password form below it is the + // admin break-glass path — no magic URL required. return view('auth.login'); } diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 000000000..ed877efb4 --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,111 @@ +client(); + $oidc->setRedirectURL(config('services.oidc.redirect_uri')); + $oidc->addScope(config('services.oidc.scopes')); + $oidc->authenticate(); // 302 to Authentik + } catch (\Throwable $e) { + Log::error('OIDC login redirect failed', ['err' => $e->getMessage()]); + return redirect('/login?password=1&oidc_error=login_redirect_failed'); + } + Log::error('OIDC authenticate() returned unexpectedly without redirecting'); + return redirect('/login?password=1&oidc_error=login_redirect_unexpected_return'); + } + + public function callback(Request $request) + { + if (!config('services.oidc.enabled')) { + return redirect('/login?password=1'); + } + try { + $oidc = $this->client(); + $oidc->setRedirectURL(config('services.oidc.redirect_uri')); + $oidc->addScope(config('services.oidc.scopes')); + $oidc->authenticate(); + } catch (\Throwable $e) { + Log::error('OIDC callback authenticate() failed', [ + 'err' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + return redirect('/login?password=1&oidc_error=callback_failed'); + } + + $sub = $oidc->getVerifiedClaims('sub'); + $userInfo = $oidc->requestUserInfo(); + $email = $userInfo->email ?? ''; + $username = $userInfo->preferred_username ?? $email; + $name = $userInfo->name ?? $username; + + Log::info('OIDC userinfo', compact('sub', 'username', 'email', 'name')); + + $map = $this->parseUsernameMap(config('services.oidc.username_map') ?? ''); + $resolver = new OidcUserResolver( + usernameMap: $map, + autoProvision: (bool)config('services.oidc.auto_provision'), + adminBreakGlassUsername: config('services.oidc.admin_breakglass_username'), + userRepo: new EloquentOidcUserRepo(), + ); + + [$heimdallUsername, $err] = $resolver->resolveUsername($username); + if ($err) { + Log::warning('OIDC username resolve refused', ['err' => $err, 'username' => $username]); + return redirect('/login?password=1&oidc_error=' . $err); + } + + [$user, $err] = $resolver->findOrProvision($heimdallUsername, $email); + if ($err) { + Log::warning('OIDC find-or-provision refused', ['err' => $err, 'username' => $heimdallUsername]); + return redirect('/login?password=1&oidc_error=' . $err); + } + + Auth::login($user, true); + session(['current_user' => $user]); + Log::info('OIDC login success', ['username' => $heimdallUsername, 'sub' => $sub]); + return redirect('/'); + } + + private function parseUsernameMap(string $raw): array + { + $out = []; + if ($raw === '') return $out; + foreach (explode(',', $raw) as $pair) { + $parts = explode(':', $pair, 2); + if (count($parts) !== 2) continue; + $out[trim($parts[0])] = trim($parts[1]); + } + return $out; + } + + private function client(): OpenIDConnectClient + { + $oidc = new OpenIDConnectClient( + config('services.oidc.issuer'), + config('services.oidc.client_id'), + config('services.oidc.client_secret'), + ); + $oidc->setVerifyHost(true); + $oidc->setVerifyPeer(true); + return $oidc; + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 6ac9249ad..4700a2df8 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -37,8 +37,13 @@ public function create(): View return view('users.create', $data); } - public function selectUser(): \Illuminate\View\View + public function selectUser(): \Illuminate\View\View|\Illuminate\Http\RedirectResponse { + // When OIDC SSO is active, skip the user-select screen entirely and + // send the authenticated user straight to their own dashboard. + if (config('services.oidc.enabled') && Auth::check()) { + return redirect()->route('dash'); + } Auth::logout(); $data['users'] = User::all(); diff --git a/app/Services/EloquentOidcUserRepo.php b/app/Services/EloquentOidcUserRepo.php new file mode 100644 index 000000000..daa568269 --- /dev/null +++ b/app/Services/EloquentOidcUserRepo.php @@ -0,0 +1,27 @@ +first(); + } + + public function create(string $username, string $email): object + { + $user = new User(); + $user->username = $username; + $user->email = $email ?: ($username . '@local.invalid'); + // Random unguessable password — never used for login. + $user->password = Hash::make(Str::random(64)); + $user->avatar = 'avatars/null.png'; + $user->save(); + return $user; + } +} diff --git a/app/Services/OidcUserRepoContract.php b/app/Services/OidcUserRepoContract.php new file mode 100644 index 000000000..783226d01 --- /dev/null +++ b/app/Services/OidcUserRepoContract.php @@ -0,0 +1,9 @@ +usernameMap[$oidcUsername] ?? $oidcUsername; + if (strcasecmp($mapped, $this->adminBreakGlassUsername) === 0) { + return [null, 'admin_breakglass_blocked']; + } + return [$mapped, null]; + } + + /** @return array{0: ?object, 1: ?string} [user, errorCode] */ + public function findOrProvision(string $username, string $email): array + { + $user = $this->userRepo->findByUsername($username); + if ($user) { + return [$user, null]; + } + if (!$this->autoProvision) { + return [null, 'user_not_found_autoprovision_disabled']; + } + $user = $this->userRepo->create($username, $email); + return [$user, null]; + } +} diff --git a/config/services.php b/config/services.php index 62e0a08a8..a7d3e9564 100644 --- a/config/services.php +++ b/config/services.php @@ -9,4 +9,17 @@ 'scheme' => 'https', ], + 'oidc' => [ + 'enabled' => env('OIDC_ENABLED', false), + 'issuer' => env('OIDC_ISSUER'), + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect_uri' => env('OIDC_REDIRECT_URI'), + 'auto_provision' => env('OIDC_AUTO_PROVISION', true), + 'admin_breakglass_username' => env('OIDC_ADMIN_BREAKGLASS_USERNAME', 'admin'), + // "src1:dst1,src2:dst2" + 'username_map' => env('OIDC_USERNAME_MAP', ''), + 'scopes' => explode(',', env('OIDC_SCOPES', 'openid,email,profile')), + ], + ]; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 00055c70d..edca2b7b4 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -8,8 +8,18 @@ ?>
{{ csrf_field() }} + @if(config('services.oidc.enabled')) + + @endif
- +
@if($user->avatar) @@ -21,7 +31,7 @@
- +
@else
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 3ad988992..eded0ed83 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -90,7 +90,9 @@ @endif {{ $current_user->username }} + @unless(config('services.oidc.enabled')) Switch User + @endunless @endif @yield('content') diff --git a/routes/web.php b/routes/web.php index 38f5f1cc5..c6968e8f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,9 @@ Route::get('/userselect/{user}', [LoginController::class, 'setUser'])->name('user.set'); Route::get('/userselect', [UserController::class, 'selectUser'])->name('user.select'); Route::get('/autologin/{uuid}', [LoginController::class, 'autologin'])->name('user.autologin'); +// OIDC SSO via Authentik. login() initiates auth-code flow, callback() handles the redirect back. +Route::get('/auth/oidc/login', [App\Http\Controllers\Auth\OidcController::class, 'login'])->name('oidc.login'); +Route::get('/auth/oidc/callback', [App\Http\Controllers\Auth\OidcController::class, 'callback'])->name('oidc.callback'); Route::get('/', [ItemController::class,'dash'])->name('dash'); Route::get('check_app_list', [ItemController::class,'checkAppList'])->name('applist'); diff --git a/tests/Unit/OidcUserResolverTest.php b/tests/Unit/OidcUserResolverTest.php new file mode 100644 index 000000000..2b8f4c548 --- /dev/null +++ b/tests/Unit/OidcUserResolverTest.php @@ -0,0 +1,118 @@ + 'aaronckj'], + autoProvision: false, + adminBreakGlassUsername: 'admin', + userRepo: new InMemoryUserRepo([]) + ); + + [$resolved, $err] = $resolver->resolveUsername('aaron.jarrett@jarrettequipment.com'); + + $this->assertSame('aaronckj', $resolved); + $this->assertNull($err); + } + + public function testReturnsIdentityWhenNoMapping(): void + { + $resolver = new OidcUserResolver([], false, 'admin', new InMemoryUserRepo([])); + [$resolved, $err] = $resolver->resolveUsername('Nhung'); + $this->assertSame('Nhung', $resolved); + $this->assertNull($err); + } + + public function testRefusesAdminBreakGlass(): void + { + $resolver = new OidcUserResolver([], true, 'admin', new InMemoryUserRepo([])); + [$resolved, $err] = $resolver->resolveUsername('admin'); + $this->assertNull($resolved); + $this->assertSame('admin_breakglass_blocked', $err); + } + + public function testRefusesAdminAfterMapping(): void + { + $resolver = new OidcUserResolver( + ['some.user@example.com' => 'admin'], + true, 'admin', new InMemoryUserRepo([]) + ); + [$resolved, $err] = $resolver->resolveUsername('some.user@example.com'); + $this->assertNull($resolved); + $this->assertSame('admin_breakglass_blocked', $err); + } + + public function testRefusesAdminCaseInsensitive(): void + { + $resolver = new OidcUserResolver([], true, 'admin', new InMemoryUserRepo([])); + [$resolved, $err] = $resolver->resolveUsername('ADMIN'); + $this->assertNull($resolved); + $this->assertSame('admin_breakglass_blocked', $err); + } + + public function testFindsExistingUserAndDoesNotMutate(): void + { + $existing = ['aaronckj' => (object)['id' => 2, 'username' => 'aaronckj', 'email' => 'old@example.com']]; + $repo = new InMemoryUserRepo($existing); + $resolver = new OidcUserResolver([], false, 'admin', $repo); + + [$user, $err] = $resolver->findOrProvision('aaronckj', 'new@example.com'); + + $this->assertNull($err); + $this->assertSame('aaronckj', $user->username); + $this->assertSame('old@example.com', $user->email, 'must NOT mutate existing user'); + $this->assertSame(0, $repo->createCount, 'must NOT create when user exists'); + } + + public function testProvisionsNewUserWhenAutoProvisionEnabled(): void + { + $repo = new InMemoryUserRepo([]); + $resolver = new OidcUserResolver([], true, 'admin', $repo); + + [$user, $err] = $resolver->findOrProvision('newuser', 'new@example.com'); + + $this->assertNull($err); + $this->assertSame('newuser', $user->username); + $this->assertSame('new@example.com', $user->email); + $this->assertSame(1, $repo->createCount); + } + + public function testRefusesNewUserWhenAutoProvisionDisabled(): void + { + $repo = new InMemoryUserRepo([]); + $resolver = new OidcUserResolver([], false, 'admin', $repo); + + [$user, $err] = $resolver->findOrProvision('newuser', 'new@example.com'); + + $this->assertNull($user); + $this->assertSame('user_not_found_autoprovision_disabled', $err); + $this->assertSame(0, $repo->createCount); + } +} + +class InMemoryUserRepo implements OidcUserRepoContract +{ + public int $createCount = 0; + public function __construct(private array $byUsername = []) {} + + public function findByUsername(string $u): ?object + { + return $this->byUsername[$u] ?? null; + } + + public function create(string $u, string $email): object + { + $this->createCount++; + $obj = (object)['id' => count($this->byUsername) + 100, 'username' => $u, 'email' => $email]; + $this->byUsername[$u] = $obj; + return $obj; + } +}