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, } }