diff --git a/backend/go.mod b/backend/go.mod index 41aaccd..2f825a1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,12 +2,18 @@ module geniusrun/backend go 1.26.4 -require github.com/mark3labs/mcp-go v0.56.0 +require ( + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/go-chi/chi/v5 v5.3.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/mark3labs/mcp-go v0.56.0 + golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.53.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.3.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -21,5 +27,4 @@ require ( modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.53.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 15b6999..8ba9ad9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -8,14 +10,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -40,18 +48,46 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/auth/mock/mock.go b/backend/internal/auth/mock/mock.go new file mode 100644 index 0000000..91811e3 --- /dev/null +++ b/backend/internal/auth/mock/mock.go @@ -0,0 +1,41 @@ +// Package mock provides a fake auth.Verifier for tests that need to exercise +// internal/api's session endpoints and RequireSession gating without a real +// Keycloak instance. +package mock + +import ( + "context" + "net/url" + + "geniusrun/backend/internal/auth" +) + +// Verifier is a fake auth.Verifier returning canned results supplied by the +// test/caller. +type Verifier struct { + AuthURL string + Txn auth.TxnState + CallbackResult auth.LoginResult + CallbackErr error + EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged +} + +var _ auth.Verifier = (*Verifier)(nil) + +func (v *Verifier) BeginLogin() (string, auth.TxnState, error) { + return v.AuthURL, v.Txn, nil +} + +func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query url.Values) (auth.LoginResult, error) { + if v.CallbackErr != nil { + return auth.LoginResult{}, v.CallbackErr + } + return v.CallbackResult, nil +} + +func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string { + if v.EndSessionResult != "" { + return v.EndSessionResult + } + return postLogoutRedirectURL +} diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go new file mode 100644 index 0000000..ee22aff --- /dev/null +++ b/backend/internal/auth/oidc.go @@ -0,0 +1,159 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Verifier is the seam between internal/api and this package's real +// Keycloak-backed OIDC client, so tests can inject internal/auth/mock's fake +// instead of talking to a real identity provider. +type Verifier interface { + // BeginLogin builds a Keycloak authorization URL and the transaction + // state that must be round-tripped (via a TxnCookie) to HandleCallback. + BeginLogin() (authURL string, txn TxnState, err error) + // HandleCallback validates the callback query against txn, exchanges the + // code, verifies the ID token, and reports whether the required role was + // present. + HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) + // EndSessionURL builds the identity provider's logout URL, redirecting + // back to postLogoutRedirectURL once Keycloak's own session is cleared. + EndSessionURL(postLogoutRedirectURL string) string +} + +// LoginResult is what a completed callback exchange resolves to. +type LoginResult struct { + Claims Claims + Authorized bool +} + +// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the +// ID token's realm_access.roles. +type OIDCConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + RequiredRole string +} + +// idTokenClaims mirrors the subset of a Keycloak ID token's claims geniusrun +// cares about. +type idTokenClaims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` +} + +func (c idTokenClaims) hasRole(required string) bool { + for _, r := range c.RealmAccess.Roles { + if r == required { + return true + } + } + return false +} + +type oidcVerifier struct { + provider *oidc.Provider + idTokenVerif *oidc.IDTokenVerifier + oauth2Config oauth2.Config + requiredRole string +} + +// NewOIDCVerifier performs OIDC discovery against cfg.IssuerURL (once, at +// startup -- go-oidc caches the discovery document internally) and returns a +// Verifier backed by the real Keycloak realm. +func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err) + } + return &oidcVerifier{ + provider: provider, + idTokenVerif: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + oauth2Config: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + requiredRole: cfg.RequiredRole, + }, nil +} + +func randomString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (v *oidcVerifier) BeginLogin() (string, TxnState, error) { + state, err := randomString(24) + if err != nil { + return "", TxnState{}, err + } + verifier := oauth2.GenerateVerifier() + authURL := v.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + return authURL, TxnState{State: state, CodeVerifier: verifier}, nil +} + +func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) { + if got := query.Get("state"); got == "" || got != txn.State { + return LoginResult{}, fmt.Errorf("state mismatch") + } + code := query.Get("code") + if code == "" { + return LoginResult{}, fmt.Errorf("callback missing code") + } + token, err := v.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(txn.CodeVerifier)) + if err != nil { + return LoginResult{}, fmt.Errorf("exchange code: %w", err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return LoginResult{}, fmt.Errorf("token response missing id_token") + } + idToken, err := v.idTokenVerif.Verify(ctx, rawIDToken) + if err != nil { + return LoginResult{}, fmt.Errorf("verify id_token: %w", err) + } + var claims idTokenClaims + if err := idToken.Claims(&claims); err != nil { + return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err) + } + return LoginResult{ + Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, + Authorized: claims.hasRole(v.requiredRole), + }, nil +} + +func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string { + var discovery struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := v.provider.Claims(&discovery); err != nil || discovery.EndSessionEndpoint == "" { + return postLogoutRedirectURL + } + u, err := url.Parse(discovery.EndSessionEndpoint) + if err != nil { + return postLogoutRedirectURL + } + q := u.Query() + q.Set("client_id", v.oauth2Config.ClientID) + q.Set("post_logout_redirect_uri", postLogoutRedirectURL) + u.RawQuery = q.Encode() + return u.String() +} diff --git a/backend/internal/auth/oidc_test.go b/backend/internal/auth/oidc_test.go new file mode 100644 index 0000000..d6f51cc --- /dev/null +++ b/backend/internal/auth/oidc_test.go @@ -0,0 +1,31 @@ +package auth + +import ( + "encoding/json" + "testing" +) + +func TestIDTokenClaims_HasRole(t *testing.T) { + cases := []struct { + name string + json string + role string + want bool + }{ + {"role present among others", `{"realm_access":{"roles":["geniusrun-user","other"]}}`, "geniusrun-user", true}, + {"role absent", `{"realm_access":{"roles":["other"]}}`, "geniusrun-user", false}, + {"realm_access missing entirely", `{}`, "geniusrun-user", false}, + {"roles array empty", `{"realm_access":{"roles":[]}}`, "geniusrun-user", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var c idTokenClaims + if err := json.Unmarshal([]byte(tc.json), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := c.hasRole(tc.role); got != tc.want { + t.Errorf("hasRole(%q) = %v, want %v", tc.role, got, tc.want) + } + }) + } +}