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
1 change: 1 addition & 0 deletions services/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ func main() {
})
})
mux.HandleFunc("/api/auth/login", server.handleLogin)
mux.HandleFunc("/api/auth/oidc", server.handleOIDCLogin)
mux.HandleFunc("/api/auth/signup", server.handleSignup)
mux.Handle("/api/events", server.auth(server.requireRole(roleAdmin, http.HandlerFunc(server.handleEvents))))
mux.Handle("/api/stats", server.auth(server.requireRole(roleAdmin, http.HandlerFunc(server.handleStats))))
Expand Down
116 changes: 116 additions & 0 deletions services/api/platform_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"

"github.com/golang-jwt/jwt/v4"
)

const platformAccessTokenTTL = 15 * time.Minute
Expand All @@ -21,6 +23,8 @@ const (
)

var platformLoginAttempts = newAPILoginAttemptTracker(time.Now)
var oidcLoginHook func(context.Context, *apiServer, string) (platformUser, error)
var errOIDCUnauthorized = errors.New("oidc unauthorized")

type apiLoginAttempt struct {
failures int
Expand Down Expand Up @@ -281,6 +285,118 @@ func (s *apiServer) handleLogin(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "bearer", "expires_in": int(platformAccessTokenTTL.Seconds()), "user": u})
}

func (s *apiServer) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.platform == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "platform identity database not configured"})
return
}
if r.Method != http.MethodPost {
w.Header().Set("allow", "POST")
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
return
}
if s.jwks == nil || strings.TrimSpace(s.oidcIssuer) == "" || strings.TrimSpace(s.oidcAudience) == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc_not_configured"})
return
}

var req struct {
IDToken string `json:"id_token"`
}
r.Body = http.MaxBytesReader(w, r.Body, 8192)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeBodyDecodeError(w, err)
return
}
idToken := strings.TrimSpace(req.IDToken)
if idToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_id_token"})
return
}

var (
u platformUser
err error
)
if oidcLoginHook != nil {
u, err = oidcLoginHook(r.Context(), s, idToken)
} else {
u, err = s.resolveOIDCLoginUser(r.Context(), idToken)
}
if err != nil {
statusCode := http.StatusInternalServerError
auditStatus := "error"
auditResource := strings.ToLower(strings.TrimSpace(u.Email))
Comment thread
Agent-Hellboy marked this conversation as resolved.
if auditResource == "" {
auditResource = oidcAuditResource(idToken)
}
if errors.Is(err, errOIDCUnauthorized) {
statusCode = http.StatusUnauthorized
auditStatus = "denied"
}
s.platform.WriteAudit(r.Context(), auditEvent{
Action: "oidc_login",
Resource: auditResource,
Status: auditStatus,
Message: err.Error(),
ActorIP: requestIP(r),
})
if statusCode == http.StatusUnauthorized {
writeJSON(w, statusCode, map[string]string{"error": "unauthorized"})
return
}
writeJSON(w, statusCode, map[string]string{"error": "login_failed"})
return
}

token, err := s.platform.CreateAccessToken(u, platformAccessTokenTTL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to issue token"})
return
}
s.platform.WriteAudit(r.Context(), auditEvent{UserID: u.ID, Action: "oidc_login", Resource: "user", Namespace: u.Namespace, Status: "success", ActorIP: requestIP(r)})
writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "bearer", "expires_in": int(platformAccessTokenTTL.Seconds()), "user": u})
}

func (s *apiServer) resolveOIDCLoginUser(ctx context.Context, idToken string) (platformUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://oidc.internal/verify", nil)
if err != nil {
return platformUser{}, err
}
req.Header.Set("authorization", "Bearer "+idToken)

p, ok, err := s.authenticateRequest(req)
if err != nil {
return platformUser{}, err
}
if !ok || p.AuthType != "oidc_jwt" {
return platformUser{}, fmt.Errorf("%w: token authentication failed", errOIDCUnauthorized)
}
if p.Subject == "" || p.Email == "" {
return platformUser{}, fmt.Errorf("%w: token missing identity", errOIDCUnauthorized)
}

return platformUser{
ID: p.Subject,
Email: p.Email,
Role: p.Role,
Namespace: p.Namespace,
}, nil
}

func oidcAuditResource(idToken string) string {
claims := jwt.MapClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(strings.TrimSpace(idToken), claims); err != nil {
return "unknown"
}
email, _ := claims["email"].(string)
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "unknown"
}
return email
}

func requestIP(r *http.Request) string {
if xff := strings.TrimSpace(r.Header.Get("x-forwarded-for")); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
Expand Down
193 changes: 193 additions & 0 deletions services/api/platform_auth_oidc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package main

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/MicahParks/keyfunc"
"github.com/golang-jwt/jwt/v4"
)

func TestHandleOIDCLoginSuccess(t *testing.T) {
previousHook := oidcLoginHook
oidcLoginHook = func(_ context.Context, _ *apiServer, token string) (platformUser, error) {
if token != "google-id-token" {
t.Fatalf("id token = %q", token)
}
return platformUser{
ID: "user-123",
Email: "user@example.com",
Role: roleUser,
Namespace: "user-1",
}, nil
}
defer func() { oidcLoginHook = previousHook }()

server := &apiServer{
platform: &platformStore{jwtSecret: []byte("test-secret")},
jwks: &keyfunc.JWKS{},
oidcIssuer: "https://issuer.example",
oidcAudience: "client-id",
}

rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{"id_token":"google-id-token"}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}

var payload struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User platformUser `json:"user"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.AccessToken == "" {
t.Fatal("expected access token")
}
if strings.Contains(payload.AccessToken, "google-id-token") {
t.Fatalf("platform token leaked raw id token: %q", payload.AccessToken)
}
if payload.TokenType != "bearer" {
t.Fatalf("token_type = %q, want bearer", payload.TokenType)
}
if payload.ExpiresIn != int(platformAccessTokenTTL.Seconds()) {
t.Fatalf("expires_in = %d, want %d", payload.ExpiresIn, int(platformAccessTokenTTL.Seconds()))
}
if payload.User.ID != "user-123" || payload.User.Email != "user@example.com" {
t.Fatalf("user payload = %+v", payload.User)
}

parsed, err := jwt.Parse(payload.AccessToken, func(t *jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("platform token parse failed: %v", err)
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("missing jwt claims")
}
if got := strings.TrimSpace(fmt.Sprint(claims["sub"])); got != "user-123" {
t.Fatalf("subject claim = %q, want user-123", got)
}
}

func TestHandleOIDCLoginRequiresPlatformStore(t *testing.T) {
server := &apiServer{
jwks: &keyfunc.JWKS{},
oidcIssuer: "https://issuer.example",
oidcAudience: "client-id",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{"id_token":"google-id-token"}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}

func TestHandleOIDCLoginRequiresOIDCConfig(t *testing.T) {
server := &apiServer{
platform: &platformStore{jwtSecret: []byte("test-secret")},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{"id_token":"google-id-token"}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}

func TestHandleOIDCLoginMissingToken(t *testing.T) {
server := &apiServer{
platform: &platformStore{jwtSecret: []byte("test-secret")},
jwks: &keyfunc.JWKS{},
oidcIssuer: "https://issuer.example",
oidcAudience: "client-id",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}

func TestHandleOIDCLoginInternalError(t *testing.T) {
previousHook := oidcLoginHook
oidcLoginHook = func(_ context.Context, _ *apiServer, _ string) (platformUser, error) {
return platformUser{Email: "user@example.com"}, errors.New("failed")
}
defer func() { oidcLoginHook = previousHook }()

server := &apiServer{
platform: &platformStore{jwtSecret: []byte("test-secret")},
jwks: &keyfunc.JWKS{},
oidcIssuer: "https://issuer.example",
oidcAudience: "client-id",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{"id_token":"google-id-token"}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}

func TestHandleOIDCLoginInvalidOIDCToken(t *testing.T) {
previousHook := oidcLoginHook
oidcLoginHook = func(_ context.Context, _ *apiServer, _ string) (platformUser, error) {
return platformUser{Email: "user@example.com"}, errOIDCUnauthorized
}
defer func() { oidcLoginHook = previousHook }()

server := &apiServer{
platform: &platformStore{jwtSecret: []byte("test-secret")},
jwks: &keyfunc.JWKS{},
oidcIssuer: "https://issuer.example",
oidcAudience: "client-id",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/oidc", strings.NewReader(`{"id_token":"google-id-token"}`))
server.handleOIDCLogin(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}

func TestOIDCAuditResourceUsesUnverifiedEmailClaim(t *testing.T) {
idToken := unsignedTestJWT(`{"email":"USER@example.COM"}`)
if got := oidcAuditResource(idToken); got != "user@example.com" {
t.Fatalf("oidcAuditResource() = %q, want user@example.com", got)
}
}

func TestOIDCAuditResourceFallsBackToUnknown(t *testing.T) {
for _, idToken := range []string{
"",
"not-a-jwt",
unsignedTestJWT(`{"sub":"user-123"}`),
} {
if got := oidcAuditResource(idToken); got != "unknown" {
t.Fatalf("oidcAuditResource(%q) = %q, want unknown", idToken, got)
}
}
}

func unsignedTestJWT(payload string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
body := base64.RawURLEncoding.EncodeToString([]byte(payload))
return header + "." + body + ".sig"
}
2 changes: 1 addition & 1 deletion services/api/platform_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ func (s *platformStore) ListNamespaces(ctx context.Context) ([]map[string]any, e
}

func (s *platformStore) WriteAudit(ctx context.Context, ev auditEvent) {
if s == nil {
if s == nil || s.db == nil {
return
}
_, _ = s.db.ExecContext(ctx, `INSERT INTO audit_logs (user_id,action,resource,namespace,status,message,actor_ip,request_id) VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,$8)`,
Expand Down
Loading
Loading