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
8 changes: 5 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,9 +886,11 @@ phase plan when ready to commit.
Coexists with root token + named tokens (`--no-auth` unchanged). Tested
against a **fake IdP** (`oidc_test.go` — real go-oidc verify: happy
path, highest-role-wins, deny-by-default, wrong-audience + expired
rejection). *Remaining:* a UI "Log in with SSO" button (frontend
follow-up — the backend is reachable directly at `/auth/oidc/login`)
and validation against a real IdP.
rejection). **UI "Log in with SSO" button SHIPPED:** the login page
probes an unauthenticated `/auth/oidc/enabled` (only mounted when OIDC
is configured) and, when present, renders a "Log in with SSO" button
linking to `/auth/oidc/login`. *Remaining:* validation against a real
IdP (the one step that needs an actual provider).
- [x] **Terraform / OpenTofu provider host** *(Tier 1 — see
[docs/direction.md](docs/direction.md))* — consume the existing
Terraform provider ecosystem (AWS, GCP, Azure, Cloudflare, …)
Expand Down
5 changes: 3 additions & 2 deletions docs/oidc-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
**Status:** **backend IMPLEMENTED** (2026-07-07) — `auth.oidc` config block,
`internal/controller/auth/oidc.go` (authenticator), `internal/controller/
server/oidc_http.go` (login+callback routes), wired into the gateway; tested
against a fake IdP (`oidc_test.go`). Remaining: a UI "Log in with SSO" button
and real-IdP validation. The design below is what shipped.
against a fake IdP (`oidc_test.go`). The UI "Log in with SSO" button is also
shipped (login page probes `/auth/oidc/enabled`). Remaining: real-IdP
validation. The design below is what shipped.
**Author:** autonomous session, 2026-07-05.
**Roadmap item:** "Multi-user auth → *Next — OIDC: external IdP → claims →
role (the last big auth slice)*" (ROADMAP.md, Future goals). Direction:
Expand Down
9 changes: 9 additions & 0 deletions internal/controller/server/oidc_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,19 @@ func NewOIDCHandler(authn *auth.OIDCAuthenticator, sessions *auth.SessionStore,

// register mounts the login + callback routes on the mux.
func (h *OIDCHandler) register(mux *http.ServeMux) {
mux.HandleFunc("/auth/oidc/enabled", h.enabled)
mux.HandleFunc("/auth/oidc/login", h.login)
mux.HandleFunc("/auth/oidc/callback", h.callback)
}

// enabled is an unauthenticated probe the login page hits to decide whether to
// render the "Log in with SSO" button. It's only registered when OIDC is
// configured, so a 200 here means SSO is available; a 404 means it isn't.
func (h *OIDCHandler) enabled(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"enabled":true}`))
}

// login starts the Authorization Code + PKCE flow: generate a state + PKCE
// verifier, stash them in short-lived HttpOnly cookies, and redirect to the IdP.
func (h *OIDCHandler) login(w http.ResponseWriter, r *http.Request) {
Expand Down
38 changes: 38 additions & 0 deletions internal/controller/server/oidc_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package server

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// The enabled probe returns 200 + {"enabled":true}. It's registered only when
// OIDC is configured, so its mere presence signals SSO availability to the
// login page.
func TestOIDCEnabledProbe(t *testing.T) {
h := &OIDCHandler{}
rec := httptest.NewRecorder()
h.enabled(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/enabled", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"enabled":true`) {
t.Errorf("body = %q, want it to report enabled:true", rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("content-type = %q, want application/json", ct)
}
}

// register mounts the enabled route (so a login page can probe it).
func TestOIDCRegisterMountsEnabled(t *testing.T) {
mux := http.NewServeMux()
(&OIDCHandler{}).register(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/enabled", nil))
if rec.Code != http.StatusOK {
t.Errorf("GET /auth/oidc/enabled = %d, want 200", rec.Code)
}
}
48 changes: 48 additions & 0 deletions ui/src/routes/Login.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,27 @@
let busy = false;
let error = '';
let tokenInput: HTMLInputElement;
// Whether the controller has OIDC configured — probed on mount. The
// /auth/oidc/enabled route is only mounted when OIDC is on (200 → show the
// SSO button; 404/error → hide it).
let ssoEnabled = false;

onMount(() => {
// Programmatic focus instead of HTML autofocus — single input, clear
// intent, and avoids Svelte's a11y_autofocus warning.
tokenInput?.focus();
void probeSSO();
});

async function probeSSO() {
try {
const resp = await fetch('/auth/oidc/enabled', { credentials: 'same-origin' });
ssoEnabled = resp.ok;
} catch {
ssoEnabled = false;
}
}

function defaultDeviceLabel(): string {
const ua = navigator.userAgent;
if (ua.includes('Macintosh')) return 'macOS browser';
Expand Down Expand Up @@ -79,6 +93,10 @@
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
{#if ssoEnabled}
<div class="sso-divider"><span>or</span></div>
<a class="sso-btn" href="/auth/oidc/login">Log in with SSO</a>
{/if}
</div>
</main>

Expand Down Expand Up @@ -143,6 +161,36 @@
font-weight: 600;
border-color: #4a8ef0;
}
.sso-divider {
display: flex;
align-items: center;
text-align: center;
color: #888;
font-size: 0.8rem;
margin: 1rem 0 0.75rem;
}
.sso-divider::before,
.sso-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid rgba(127, 127, 127, 0.25);
}
.sso-divider span {
padding: 0 0.6rem;
}
.sso-btn {
display: block;
text-align: center;
padding: 0.6em 1em;
border: 1px solid rgba(127, 127, 127, 0.4);
border-radius: 6px;
color: inherit;
text-decoration: none;
font-weight: 600;
}
.sso-btn:hover {
background: rgba(127, 127, 127, 0.1);
}
button:hover {
background: #3a7ee0;
}
Expand Down
Loading