Files
anchorage/internal/pkg/openapi/session.go
William Gill 90ac4b169c
Some checks failed
Security / Vulnerability Check (push) Successful in 1m47s
Test / Build & Unit Tests (push) Successful in 5m4s
Test / Lint (push) Successful in 27s
Test / Integration Tests (push) Failing after 1m58s
ci: fix first CI run — tidy, gofmt, ordering test, govulncheck pin
- ids: TestIDsAreTimeOrdered asserted strict lexicographic ordering of
  back-to-back UUIDv7s, but the sub-ms tail is random and not required
  to be monotonic. Sleep between samples so each ID lands in a distinct
  millisecond — the property that actually gives Postgres index
  locality on (org_id, id desc).
- go.mod/go.sum: run go mod tidy. keyfunc/v3, prometheus/client_golang
  and testcontainers-go/modules/postgres are imported directly and
  should not be marked // indirect; also drops stale sum entries.
- gofmt -w across 12 files flagged by the lint job.
- security.yml: pin govulncheck to v1.2.0. @latest triggers a proxy
  lookup every run, which is the step that hung for 16m on the Gitea
  runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:18:49 -05:00

306 lines
9.4 KiB
Go

package openapi
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"anchorage/internal/pkg/auth"
"anchorage/internal/pkg/ids"
"anchorage/internal/pkg/store"
"anchorage/internal/pkg/token"
)
// SessionDeps wires the session endpoints to the store, the token
// signer, and the Authentik verifier.
type SessionDeps struct {
Store store.Store
Signer *token.Signer
// OIDC may be nil in environments without Authentik (dev, tests).
// The session POST will 503 in that case with a clear message.
OIDC *auth.OIDCVerifier
// Sysadmins is the list of emails that get promoted to sysadmin on
// their first login — mirrors config.BootstrapConfig.Sysadmins.
Sysadmins []string
// SessionTTL controls how long the issued session JWT lives.
SessionTTL time.Duration
// CookieSecure controls whether the Set-Cookie uses `Secure`. True
// in production; false is only appropriate for plain-HTTP local dev.
CookieSecure bool
}
// RegisterSession wires POST /auth/session, DELETE /auth/session, and
// GET /me onto api.
func RegisterSession(api huma.API, deps SessionDeps) {
// --- POST /auth/session ------------------------------------------------
huma.Register(api, huma.Operation{
OperationID: "createSession",
Method: "POST",
Path: "/auth/session",
Summary: "Exchange an Authentik ID token for an anchorage session",
Description: `The web UI completes Authorization Code + PKCE against Authentik,
then POSTs the resulting ID token here. anchorage validates the
signature + issuer + audience against the JWKS, upserts the user,
promotes bootstrap sysadmins on first login, and returns a session
JWT as an HttpOnly cookie.`,
}, func(ctx context.Context, in *struct {
Body createSessionRequest
}) (*createSessionResponse, error) {
if deps.OIDC == nil {
return nil, huma.Error503ServiceUnavailable(
"auth: OIDC is not configured — set auth.authentik.issuer/clientID/audience in anchorage.yaml")
}
claims, err := deps.OIDC.Verify(ctx, in.Body.IDToken)
if err != nil {
return nil, huma.Error401Unauthorized("auth: " + err.Error())
}
user, err := upsertUser(ctx, deps, claims)
if err != nil {
return nil, huma.Error500InternalServerError("auth: upsert user", err)
}
sessionJWT, sessionClaims, err := mintSession(ctx, deps, user)
if err != nil {
return nil, huma.Error500InternalServerError("auth: mint session", err)
}
out := &createSessionResponse{}
out.SetCookie = sessionCookieHeader(sessionJWT, deps)
out.Body.User = userBody(user)
out.Body.ExpiresAt = sessionClaims.ExpiresAt.Time
return out, nil
})
// --- DELETE /auth/session ----------------------------------------------
huma.Register(api, huma.Operation{
OperationID: "deleteSession",
Method: "DELETE",
Path: "/auth/session",
Summary: "Log out — revoke the session cookie",
Security: []map[string][]string{{"accessToken": {}}},
}, func(ctx context.Context, _ *struct{}) (*deleteSessionResponse, error) {
cc, err := auth.Require(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("auth")
}
// Add the jti to the denylist so the cookie-bearer can't keep
// using the raw JWT elsewhere until natural expiry.
if cc.TokenJTI != nil {
expiry := time.Now().Add(deps.SessionTTL)
if err := deps.Store.Tokens().AddDenylist(ctx, *cc.TokenJTI, expiry, "logout"); err != nil {
// Non-fatal: clearing the cookie still improves the user's
// situation even if denylist write fails.
_ = err
}
}
out := &deleteSessionResponse{}
// Empty Value + MaxAge=-1 tells the browser to drop the cookie.
out.SetCookie = clearCookieHeader(deps)
return out, nil
})
// --- GET /me ----------------------------------------------------------
huma.Register(api, huma.Operation{
OperationID: "getMe",
Method: "GET",
Path: "/me",
Summary: "Current authenticated user",
Security: []map[string][]string{{"accessToken": {}}},
}, func(ctx context.Context, _ *struct{}) (*struct {
Body meBody
}, error) {
cc, err := auth.Require(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("auth")
}
user, err := deps.Store.Users().GetByID(ctx, cc.User)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, huma.Error404NotFound("user")
}
return nil, huma.Error500InternalServerError("get user", err)
}
memberships, err := deps.Store.Memberships().ListForUser(ctx, cc.User)
if err != nil {
return nil, huma.Error500InternalServerError("list memberships", err)
}
out := &struct{ Body meBody }{}
out.Body.User = userBody(user)
out.Body.Role = cc.Role
for _, m := range memberships {
out.Body.Memberships = append(out.Body.Memberships, membershipBody{
OrgID: m.OrgID.String(),
OrgSlug: m.OrgSlug,
OrgName: m.OrgName,
Role: m.Role,
})
}
return out, nil
})
}
// ---------------------------------------------------------------------------
// Request / response bodies
// ---------------------------------------------------------------------------
type createSessionRequest struct {
// IDToken is the raw JWT returned by Authentik at the end of the
// Authorization Code + PKCE dance.
IDToken string `json:"id_token" minLength:"32"`
}
type createSessionResponse struct {
// SetCookie is emitted as a Set-Cookie header so the browser picks
// up the session JWT. huma knows `header:"Set-Cookie"` means a
// response header, one entry per cookie.
SetCookie string `header:"Set-Cookie"`
Body struct {
User userBodyT `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
}
type deleteSessionResponse struct {
SetCookie string `header:"Set-Cookie"`
}
type meBody struct {
User userBodyT `json:"user"`
Role string `json:"role"`
Memberships []membershipBody `json:"memberships"`
}
type userBodyT struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
IsSysadmin bool `json:"is_sysadmin"`
}
type membershipBody struct {
OrgID string `json:"org_id"`
OrgSlug string `json:"org_slug"`
OrgName string `json:"org_name"`
Role string `json:"role"`
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func upsertUser(ctx context.Context, deps SessionDeps, claims *auth.OIDCClaims) (*store.User, error) {
// Check if this Authentik subject already maps to a user. If yes,
// we don't need a fresh TypeID — upsert keeps the existing one.
existing, err := deps.Store.Users().GetByEmail(ctx, claims.Email)
var userID ids.UserID
switch {
case err == nil:
userID = existing.ID
case errors.Is(err, store.ErrNotFound):
userID, err = ids.NewUser()
if err != nil {
return nil, err
}
default:
return nil, err
}
isSysadmin := bootstrapSysadmin(claims.Email, deps.Sysadmins)
// Preserve existing sysadmin status (don't demote an admin just
// because their email was removed from config).
if existing != nil && existing.IsSysadmin {
isSysadmin = true
}
return deps.Store.Users().UpsertByAuthentikSub(ctx, userID, claims.Sub, claims.Email, claims.DisplayName(), isSysadmin)
}
// bootstrapSysadmin matches the email case-insensitively against the
// config list. Authentik is case-preserving on the `email` claim which
// is a common footgun when operators type the email with different
// capitalisation in config vs. Authentik.
func bootstrapSysadmin(email string, sysadmins []string) bool {
target := strings.ToLower(strings.TrimSpace(email))
for _, s := range sysadmins {
if strings.ToLower(strings.TrimSpace(s)) == target {
return true
}
}
return false
}
// mintSession mints an anchorage-signed JWT representing the session.
// The role comes from the user's sysadmin flag; non-sysadmins default
// to "member" and attach the first membership's org if any exists (so
// the handlers can resolve cc.Org without a separate lookup).
func mintSession(ctx context.Context, deps SessionDeps, user *store.User) (string, *token.Claims, error) {
role := store.RoleMember
if user.IsSysadmin {
role = "sysadmin"
}
var orgID ids.OrgID
memberships, _ := deps.Store.Memberships().ListForUser(ctx, user.ID)
if len(memberships) > 0 {
orgID = memberships[0].OrgID
} else {
// No org yet — synthetic TypeID so the JWT is well-formed. The
// user has to be granted org membership (or promoted sysadmin)
// before this token is useful for tenant-scoped endpoints.
fresh, err := ids.NewOrg()
if err != nil {
return "", nil, err
}
orgID = fresh
}
ttl := deps.SessionTTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
return deps.Signer.Mint(ctx, orgID, user.ID, role, nil, ttl)
}
func sessionCookieHeader(jwtValue string, deps SessionDeps) string {
parts := []string{
fmt.Sprintf("%s=%s", auth.SessionCookieName, jwtValue),
"Path=/",
"HttpOnly",
"SameSite=Lax",
fmt.Sprintf("Max-Age=%d", int(deps.SessionTTL.Seconds())),
}
if deps.CookieSecure {
parts = append(parts, "Secure")
}
return strings.Join(parts, "; ")
}
func clearCookieHeader(deps SessionDeps) string {
parts := []string{
fmt.Sprintf("%s=", auth.SessionCookieName),
"Path=/",
"HttpOnly",
"SameSite=Lax",
"Max-Age=0",
}
if deps.CookieSecure {
parts = append(parts, "Secure")
}
return strings.Join(parts, "; ")
}
func userBody(u *store.User) userBodyT {
return userBodyT{
ID: u.ID.String(),
Email: u.Email,
DisplayName: u.DisplayName,
IsSysadmin: u.IsSysadmin,
}
}