Skip to content
Open
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
62 changes: 62 additions & 0 deletions AUTHENTIK_OIDC.md
Original file line number Diff line number Diff line change
@@ -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://<your-heimdall-host>/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://<authentik-host>/application/o/<app-slug>/.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://<authentik-host>/application/o/<app-slug>/ \
-e OIDC_CLIENT_ID=<client-id> \
-e OIDC_CLIENT_SECRET=<client-secret> \
-e OIDC_REDIRECT_URI=https://<your-heimdall-host>/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.
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions app/Http/Controllers/Auth/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
111 changes: 111 additions & 0 deletions app/Http/Controllers/Auth/OidcController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Services\EloquentOidcUserRepo;
use App\Services\OidcUserResolver;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Jumbojett\OpenIDConnectClient;

// NOTE: jumbojett uses raw $_SESSION for OIDC state/nonce. Verify Phase 5 that
// state validation actually works under Laravel's session middleware — if not,
// add a session_start() shim or inject a custom OIDC session handler.
class OidcController extends Controller
{
public function login(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(); // 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;
}
}
7 changes: 6 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
27 changes: 27 additions & 0 deletions app/Services/EloquentOidcUserRepo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Services;

use App\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class EloquentOidcUserRepo implements OidcUserRepoContract
{
public function findByUsername(string $username): ?object
{
return User::where('username', $username)->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;
}
}
9 changes: 9 additions & 0 deletions app/Services/OidcUserRepoContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace App\Services;

interface OidcUserRepoContract
{
public function findByUsername(string $username): ?object;
public function create(string $username, string $email): object;
}
37 changes: 37 additions & 0 deletions app/Services/OidcUserResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Services;

class OidcUserResolver
{
public function __construct(
private array $usernameMap,
private bool $autoProvision,
private string $adminBreakGlassUsername,
private OidcUserRepoContract $userRepo,
) {}

/** @return array{0: ?string, 1: ?string} [resolvedUsername, errorCode] */
public function resolveUsername(string $oidcUsername): array
{
$mapped = $this->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];
}
}
13 changes: 13 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
],

];
14 changes: 12 additions & 2 deletions resources/views/auth/login.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@
?>
<form class="form-horizontal" method="POST" action="{{ route('login') }}">
{{ csrf_field() }}
@if(config('services.oidc.enabled'))
<div class="oidc-login" style="margin-bottom: 1.5rem; text-align: center;">
<a href="{{ route('oidc.login') }}" style="display:inline-block; padding:.75rem 1.5rem; background:#fd4b2d; color:#fff; text-decoration:none; border-radius:4px; font-size:1rem;">
Sign in with Authentik
</a>
<div style="margin-top:1.25rem; border-top:1px solid #333; padding-top:1rem; font-size:.8rem; color:#666; letter-spacing:.05em; text-transform:uppercase;">
Admin local login
</div>
</div>
@endif
<div class="userlist">

<div class="user" href="{{ route('user.set', [$user->id]) }}">
@if($user->avatar)
<img class="user-img" src="{{ asset('/storage/'.$user->avatar) }}" />
Expand All @@ -21,7 +31,7 @@
<button type="submit" class="btn btn-primary">Login</button>
</div>
</div>

</form>
@else
<section class="module-container">
Expand Down
2 changes: 2 additions & 0 deletions resources/views/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@
<img class="user-img" src="{{ asset('/img/heimdall-icon-small.png') }}" />
@endif
{{ $current_user->username }}
@unless(config('services.oidc.enabled'))
<a class="btn" href="{{ route('user.select') }}">Switch User</a>
@endunless
</div>
@endif
@yield('content')
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading