diff --git a/backend/internal/domain/hydra_models_test.go b/backend/internal/domain/hydra_models_test.go new file mode 100644 index 00000000..7d1af640 --- /dev/null +++ b/backend/internal/domain/hydra_models_test.go @@ -0,0 +1,60 @@ +package domain + +import "testing" + +func TestHydraClient_TrustedRPFlags(t *testing.T) { + t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKS: map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + }}, + }, + Metadata: map[string]any{ + "headless_login_enabled": true, + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected trusted rp") + } + if !client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login enabled") + } + }) + + t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "none", + JWKSUri: "https://rp.example.com/.well-known/jwks.json", + Metadata: map[string]any{ + "headless_login_enabled": true, + }, + } + + if client.IsTrustedRP() { + t.Fatalf("expected untrusted rp") + } + if client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login disabled when client is not trusted") + } + }) + + t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "private_key_jwt", + JWKSUri: "https://rp.example.com/.well-known/jwks.json", + Metadata: map[string]any{ + "headless_login_enabled": "true", + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected trusted rp") + } + if client.IsHeadlessLoginEnabled() { + t.Fatalf("expected headless login disabled for non-bool metadata") + } + }) +} diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 37a55a09..334c3e57 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -610,6 +611,156 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) { }) } +func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + return httpJSONAny(r, http.StatusCreated, map[string]any{ + "client_id": captured.ClientID, + "client_name": captured.ClientName, + "redirect_uris": captured.RedirectURIs, + "grant_types": captured.GrantTypes, + "response_types": captured.ResponseTypes, + "scope": captured.Scope, + "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, + "jwks": captured.JWKS, + "metadata": captured.Metadata, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Trusted RP App", + "type": "pkce", + "redirectUris": []string{"https://rp.example.com/callback"}, + "scopes": []string{"openid", "profile"}, + "tokenEndpointAuthMethod": "private_key_jwt", + "jwks": map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + "alg": "RS256", + "n": "AQIDBAUGBw", + "e": "AQAB", + }}, + }, + "metadata": map[string]any{ + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.NotNil(t, captured.JWKS) + assert.True(t, captured.IsTrustedRP()) + assert.True(t, captured.IsHeadlessLoginEnabled()) + assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) + assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) +} + +func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { + var captured domain.HydraClient + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-trusted", + "client_name": "Trusted Before", + "redirect_uris": []string{"https://before.example.com/callback"}, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "openid profile", + "token_endpoint_auth_method": "none", + "metadata": map[string]any{ + "status": "active", + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + err = json.Unmarshal(body, &captured) + assert.NoError(t, err) + + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": captured.ClientID, + "client_name": captured.ClientName, + "redirect_uris": captured.RedirectURIs, + "grant_types": captured.GrantTypes, + "response_types": captured.ResponseTypes, + "scope": captured.Scope, + "token_endpoint_auth_method": captured.TokenEndpointAuthMethod, + "jwks_uri": captured.JWKSUri, + "metadata": captured.Metadata, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra.public", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "Trusted After", + "type": "pkce", + "tokenEndpointAuthMethod": "private_key_jwt", + "jwksUri": "https://rp.example.com/.well-known/jwks.json", + "metadata": map[string]any{ + "headless_login_enabled": true, + "request_object_signing_alg": "RS256", + }, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) + assert.True(t, captured.IsTrustedRP()) + assert.True(t, captured.IsHeadlessLoginEnabled()) + assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) +} + func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index b88c4e29..7f24a741 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -8,6 +8,8 @@ import { } from "./helpers/devfront-fixtures"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; +const sshRsaPublicKey = + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAABwECAwQFBgc= test@example"; test.describe("DevFront clients lifecycle", () => { test.beforeEach(async ({ page }) => { @@ -120,4 +122,69 @@ test.describe("DevFront clients lifecycle", () => { page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }), ).toHaveValue(/https:\/\/after\.example\.com\/callback/); }); + + test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({ + page, + }) => { + const state = { + clients: [makeClient("client-trusted", { name: "Trusted App", type: "pkce" })], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.goto("/clients/client-trusted/settings"); + + await page + .getByRole("switch", { + name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i, + }) + .click(); + + await expect( + page.getByRole("heading", { + name: /공개키 등록|Public Key Registration/i, + }), + ).toBeVisible(); + + await page + .getByPlaceholder( + /ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i, + ) + .fill(sshRsaPublicKey); + await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); + + await expect.poll(() => state.clients[0]?.tokenEndpointAuthMethod).toBe( + "private_key_jwt", + ); + await expect + .poll(() => state.clients[0]?.metadata?.headless_login_enabled) + .toBe(true); + await expect + .poll( + () => + (state.clients[0]?.jwks as { keys?: Array<{ kty?: string; alg?: string }> }) + ?.keys?.[0]?.kty, + ) + .toBe("RSA"); + await expect + .poll( + () => + (state.clients[0]?.jwks as { keys?: Array<{ kty?: string; alg?: string }> }) + ?.keys?.[0]?.alg, + ) + .toBe("RS256"); + + await page.reload(); + await expect( + page.getByRole("heading", { + name: /공개키 등록|Public Key Registration/i, + }), + ).toBeVisible(); + await expect( + page.getByPlaceholder( + /ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i, + ), + ).toHaveValue(/"kty": "RSA"/); + }); }); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index a8d26f67..ee63d7b5 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -12,6 +12,9 @@ export type Client = { scopes: string[]; createdAt: string; clientSecret?: string; + tokenEndpointAuthMethod?: string; + jwksUri?: string; + jwks?: Record | string; metadata?: Record; }; @@ -214,6 +217,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { status?: ClientStatus; redirectUris?: string[]; scopes?: string[]; + tokenEndpointAuthMethod?: string; + jwksUri?: string; + jwks?: Record | string; metadata?: Record; }) || { name: "created app" }; @@ -223,6 +229,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { status: payload.status ?? "active", redirectUris: payload.redirectUris ?? [], scopes: payload.scopes ?? ["openid"], + tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod, + jwksUri: payload.jwksUri, + jwks: payload.jwks, metadata: payload.metadata ?? {}, }); @@ -294,6 +303,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { type?: ClientType; scopes?: string[]; redirectUris?: string[]; + tokenEndpointAuthMethod?: string; + jwksUri?: string; + jwks?: Record | string; metadata?: Record; }) || { name: "updated app" }; const found = state.clients.find((client) => client.id === clientId); @@ -302,6 +314,15 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { if (payload.type) found.type = payload.type; if (payload.scopes) found.scopes = payload.scopes; if (payload.redirectUris) found.redirectUris = payload.redirectUris; + if (payload.tokenEndpointAuthMethod !== undefined) { + found.tokenEndpointAuthMethod = payload.tokenEndpointAuthMethod; + } + if (payload.jwksUri !== undefined) { + found.jwksUri = payload.jwksUri; + } + if (payload.jwks !== undefined) { + found.jwks = payload.jwks; + } if (payload.metadata) found.metadata = payload.metadata; appendAuditLog("CLIENT_UPDATE", "UPDATE_CLIENT", clientId); return json(route, {