diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 6fb7c383..0c318e28 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -15,7 +15,7 @@ import ( // NewWellKnownController creates a new controller for OIDC discovery endpoints // @Summary OIDC Discovery controller -// @Description Initializes OIDC discovery and JWKS endpoints +// @Description Initializes OIDC discovery, OAuth 2.0 authorization server metadata and JWKS endpoints // @Tags Well Known func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService, getCIMDURLAllowlist func() []string) { wkc := &WellKnownController{ @@ -25,6 +25,7 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi group.GET("/.well-known/jwks.json", httpserver.Handle(wkc.jwksHandler)) group.GET("/.well-known/openid-configuration", httpserver.Handle(wkc.openIDConfigurationHandler)) + group.GET("/.well-known/oauth-authorization-server", httpserver.Handle(wkc.oauthAuthorizationServerHandler)) } type WellKnownController struct { @@ -58,15 +59,30 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) error { // @Failure default {object} dto.ErrorDto "Error" // @Router /.well-known/openid-configuration [get] func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) error { - oidcConfig, err := wkc.computeOIDCConfiguration() + return wkc.writeServerMetadata(c) +} + +// oauthAuthorizationServerHandler godoc +// @Summary Get OAuth 2.0 authorization server metadata +// @Description Returns the RFC 8414 OAuth 2.0 authorization server metadata document with endpoints and capabilities +// @Tags Well Known +// @Success 200 {object} object "OAuth 2.0 authorization server metadata" +// @Failure default {object} dto.ErrorDto "Error" +// @Router /.well-known/oauth-authorization-server [get] +func (wkc *WellKnownController) oauthAuthorizationServerHandler(c *gin.Context) error { + return wkc.writeServerMetadata(c) +} + +func (wkc *WellKnownController) writeServerMetadata(c *gin.Context) error { + metadata, err := wkc.computeServerMetadata() if err != nil { return err } - c.Data(http.StatusOK, "application/json; charset=utf-8", oidcConfig) + c.Data(http.StatusOK, "application/json; charset=utf-8", metadata) return nil } -func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { +func (wkc *WellKnownController) computeServerMetadata() ([]byte, error) { appUrl := common.EnvConfig.AppURL internalAppUrl := common.EnvConfig.InternalAppURL @@ -81,18 +97,20 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { } config := map[string]any{ - "issuer": appUrl, - "authorization_endpoint": appUrl + "/authorize", - "token_endpoint": internalAppUrl + "/api/oidc/token", - "userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo", - "end_session_endpoint": appUrl + "/api/oidc/end-session", - "introspection_endpoint": internalAppUrl + "/api/oidc/introspect", + "issuer": appUrl, + "authorization_endpoint": appUrl + "/authorize", + "token_endpoint": internalAppUrl + "/api/oidc/token", + "userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo", + "end_session_endpoint": appUrl + "/api/oidc/end-session", + "introspection_endpoint": internalAppUrl + "/api/oidc/introspect", + "introspection_endpoint_auth_methods_supported": []string{"client_secret_basic", "Bearer"}, "device_authorization_endpoint": appUrl + "/api/oidc/device/authorize", "jwks_uri": internalAppUrl + "/.well-known/jwks.json", "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials}, "scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"}, - "response_types_supported": []string{"code", "id_token"}, + "response_types_supported": []string{"code"}, + "response_modes_supported": []string{"query", "fragment", "form_post"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{alg.String()}, "authorization_response_iss_parameter_supported": true, @@ -105,6 +123,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { "pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par", "require_pushed_authorization_requests": false, "client_id_metadata_document_supported": cimdSupported, + "service_documentation": "https://pocket-id.org/docs", } return json.Marshal(config) } diff --git a/backend/internal/controller/well_known_controller_test.go b/backend/internal/controller/well_known_controller_test.go index fee1d095..516b851c 100644 --- a/backend/internal/controller/well_known_controller_test.go +++ b/backend/internal/controller/well_known_controller_test.go @@ -2,8 +2,11 @@ package controller import ( "encoding/json" + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,7 +45,7 @@ func TestClientIDMetadataDocumentDiscoveryFollowsAllowlist(t *testing.T) { parse := func(t *testing.T) map[string]any { t.Helper() - raw, err := wkc.computeOIDCConfiguration() + raw, err := wkc.computeServerMetadata() require.NoError(t, err) var cfg map[string]any require.NoError(t, json.Unmarshal(raw, &cfg)) @@ -55,3 +58,55 @@ func TestClientIDMetadataDocumentDiscoveryFollowsAllowlist(t *testing.T) { cimdURLAllowlist = nil assert.Equal(t, false, parse(t)["client_id_metadata_document_supported"]) } + +func TestOAuthAuthorizationServerMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + origAppURL := common.EnvConfig.AppURL + origInternalAppURL := common.EnvConfig.InternalAppURL + t.Cleanup(func() { + common.EnvConfig.AppURL = origAppURL + common.EnvConfig.InternalAppURL = origInternalAppURL + }) + common.EnvConfig.AppURL = "https://test.example.com" + common.EnvConfig.InternalAppURL = "https://test.example.com" + + router := gin.New() + NewWellKnownController(router.Group("/"), newMinimalJwtService(t), func() []string { return nil }) + + get := func(t *testing.T, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + w := get(t, "/.well-known/oauth-authorization-server") + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) + + var doc map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc)) + + assert.Equal(t, common.EnvConfig.AppURL, doc["issuer"]) + assert.Equal(t, common.EnvConfig.AppURL+"/authorize", doc["authorization_endpoint"]) + assert.Equal(t, common.EnvConfig.InternalAppURL+"/api/oidc/token", doc["token_endpoint"]) + assert.Contains(t, doc["response_types_supported"], "code") + assert.NotEmpty(t, doc["jwks_uri"]) + assert.Contains(t, doc["scopes_supported"], "openid") + assert.Contains(t, doc["grant_types_supported"], "authorization_code") + assert.Contains(t, doc["code_challenge_methods_supported"], "S256") + assert.Equal(t, "https://pocket-id.org/docs", doc["service_documentation"]) + assert.ElementsMatch(t, []any{"query", "fragment", "form_post"}, doc["response_modes_supported"]) + assert.NotContains(t, doc, "revocation_endpoint") + assert.NotContains(t, doc, "registration_endpoint") + + for name, value := range doc { + if arr, ok := value.([]any); ok { + assert.NotEmpty(t, arr, "metadata member %q must be omitted when it has no values", name) + } + } + + assert.JSONEq(t, get(t, "/.well-known/openid-configuration").Body.String(), w.Body.String()) +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index 4a1cc85b..62686f65 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -46,7 +46,8 @@ func isCorsPath(path string) bool { "/api/oidc/end-session", "/api/oidc/introspect", "/.well-known/jwks.json", - "/.well-known/openid-configuration": + "/.well-known/openid-configuration", + "/.well-known/oauth-authorization-server": return true default: return false diff --git a/backend/internal/middleware/cors_test.go b/backend/internal/middleware/cors_test.go new file mode 100644 index 00000000..e639a690 --- /dev/null +++ b/backend/internal/middleware/cors_test.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCorsMiddlewareAllowsDiscoveryDocuments(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(NewCorsMiddleware().Add()) + + paths := []string{"/.well-known/openid-configuration", "/.well-known/oauth-authorization-server"} + for _, path := range paths { + router.GET(path, func(c *gin.Context) { + c.Status(http.StatusOK) + }) + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + + req = httptest.NewRequestWithContext(t.Context(), http.MethodOptions, path, http.NoBody) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) + require.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + }) + } +}