- 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>
306 lines
9.4 KiB
Go
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,
|
|
}
|
|
}
|