From 2fe4bc5681f70886300757a62e738f7168b08e28 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 30 May 2026 16:57:31 +0530 Subject: [PATCH] feat(api,mcp): migrate 7 stubs; security audit fixes; lock stdio-only MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 7 of the 17 stubbed AuthorizerService methods (Profile, Permissions, Logout, Revoke, ValidateJwtToken, ValidateSession, Session) following the established service-layer pattern, and addresses the security audit findings against the MCP surface. SECURITY AUDIT FIXES C1 — Session response carries access_token / refresh_token / id_token / authenticator_secret / recovery_codes. The proto annotation on Session flipped to mcp_tool.exposed = false so those credentials never land in an LLM transcript. Session remains available via gRPC + REST + GraphQL for legitimate browser/server-to-server consumers. H1 — MCP→gRPC auth propagation. New `--mcp-bearer` flag on the `authorizer mcp` subcommand; the MCP server stamps `Authorization: Bearer ` on every outgoing gRPC call. Identity-bearing tools (profile, permissions) now have a caller to attribute to; anonymous runs still work for the public Meta tool but identity-bearing tools surface a clean unauthorized error. H2 — Recovery interceptor redacts panic values. The recovered value is no longer dumped via `.Interface("panic", r)` (which would have logged credentials if a handler ever panicked with the request struct); only the panic type is logged for triage. Regression test included. STDIO-ONLY MCP TRANSPORT internal/mcp/server.go — explicit type-level documentation: stdio is the ONLY supported transport. The Server has no RunHTTP / RunTCP / RunSSE methods, intentionally. internal/mcp/transport_test.go — `TestServer_StdioOnly` reflects over *Server's exported methods and fails the build if anyone adds a method whose name suggests a network transport (RunHTTP, ListenTCP, ServeWS, etc.). To add a transport: implement an MCP-side auth interceptor first, then update the allow-list. cmd/mcp.go — docstring + CLI long help explicitly state "stdio only". 7 STUB MIGRATIONS internal/service: profile.go, permissions.go, logout.go, revoke.go, validate_jwt_token.go, validate_session.go, session.go, permission_check.go (shared helper). All follow the SignUp pattern: take RequestMetadata, return (result, *ResponseSideEffects, error). internal/grpcsrv/handlers: authorizer.go grows 4 real method implementations (Profile, Permissions, Logout, Revoke, ValidateJwtToken, ValidateSession, Session). project.go adds projectUser / projectAuthResponse / projectAppData / claimsToAppData / protoToModelPermissions helpers shared across methods. internal/graphql: resolvers for the seven ops become thin delegations (same pattern as Signup + Meta). internal/cookie: BuildDeleteSessionCookies added; DeleteSession now delegates to it (transport-agnostic mirror of the existing pattern). internal/service/provider.go: Dependencies grows AuthorizationProvider; the four new methods land on the Provider interface. All call sites (cmd/root, cmd/mcp, test_helper) wire it through. TESTS - TestRecovery_DoesNotLogCredentialBearingPanicValue (H2 regression) - TestServer_StdioOnly (transport lock-down) - TestMCPListAndCallMeta now expects 3 MCP tools (meta/profile/permissions); session was DROPPED per C1. - TestMCPToolErrorSurfacesAsIsErrorResult exercises anonymous call to identity-bearing tool (formerly the "stubbed tool" test). - TestAuthorizerServiceStubsReturnUnimplemented shrunk by 7 entries. - Full SQLite integration suite (67s) still green — no regression on the existing GraphQL behaviour for any of the 7 migrated ops. STILL STUBBED (10 ops, follow-up PRs) Login, MagicLinkLogin, VerifyEmail, ResendVerifyEmail, VerifyOtp, ResendOtp, ForgotPassword, ResetPassword, UpdateProfile, DeactivateAccount. Each is a substantial state machine; better as focused individual PRs than rushed in a batch. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/mcp.go | 122 ++++++++++--- cmd/root.go | 22 +-- gen/go/authorizer/v1/authorizer.pb.go | 95 +++++----- gen/go/authorizer/v1/authorizer_grpc.pb.go | 6 + gen/openapi/authorizer.swagger.json | 2 +- internal/cookie/cookie.go | 41 ++++- internal/graphql/logout.go | 48 +----- internal/graphql/permissions.go | 57 +----- internal/graphql/profile.go | 27 +-- internal/graphql/revoke.go | 63 +------ internal/graphql/session.go | 159 +---------------- internal/graphql/validate_jwt_token.go | 130 +------------- internal/graphql/validate_session.go | 67 +------- internal/grpcsrv/handlers/authorizer.go | 105 +++++++++++- internal/grpcsrv/handlers/project.go | 157 +++++++++++++++++ .../grpcsrv/interceptors/interceptors_test.go | 28 ++- internal/grpcsrv/interceptors/recovery.go | 10 +- .../integration_tests/grpc_surface_test.go | 35 +--- internal/integration_tests/mcp_stubs_test.go | 35 ++-- internal/integration_tests/mcp_test.go | 12 +- internal/integration_tests/test_helper.go | 19 +- internal/mcp/server.go | 92 +++++++--- internal/mcp/transport_test.go | 42 +++++ internal/service/logout.go | 57 ++++++ internal/service/permission_check.go | 60 +++++++ internal/service/permissions.go | 61 +++++++ internal/service/profile.go | 36 ++++ internal/service/provider.go | 43 ++++- internal/service/revoke.go | 63 +++++++ internal/service/session.go | 162 ++++++++++++++++++ internal/service/validate_jwt_token.go | 117 +++++++++++++ internal/service/validate_session.go | 77 +++++++++ proto/authorizer/v1/authorizer.proto | 4 +- 33 files changed, 1356 insertions(+), 698 deletions(-) create mode 100644 internal/grpcsrv/handlers/project.go create mode 100644 internal/mcp/transport_test.go create mode 100644 internal/service/logout.go create mode 100644 internal/service/permission_check.go create mode 100644 internal/service/permissions.go create mode 100644 internal/service/profile.go create mode 100644 internal/service/revoke.go create mode 100644 internal/service/session.go create mode 100644 internal/service/validate_jwt_token.go create mode 100644 internal/service/validate_session.go diff --git a/cmd/mcp.go b/cmd/mcp.go index 7fa2f87f..64c13426 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -10,32 +10,59 @@ import ( "github.com/spf13/cobra" "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/authorization" "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/email" + "github.com/authorizerdev/authorizer/internal/events" "github.com/authorizerdev/authorizer/internal/grpcsrv" "github.com/authorizerdev/authorizer/internal/mcp" + "github.com/authorizerdev/authorizer/internal/memory_store" "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/sms" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/token" ) +// mcpArgs are the MCP-subcommand-only flags. The root command's flags +// (--database-type, --client-id, --jwt-secret, ...) are inherited by the +// subcommand automatically since they live on RootCmd. +var mcpArgs struct { + // bearer is propagated as `Authorization: Bearer ` on every + // outgoing gRPC call. Without it the MCP server runs anonymously — + // fine for the `meta` tool (public) but identity-bearing tools + // (`profile`, `permissions`) won't have a caller to attribute to. + bearer string +} + // mcpCmd serves Authorizer's MCP surface over stdio. Designed to be wired // into Claude Code or any other MCP host via: // // claude mcp add authorizer -- /path/to/authorizer mcp --client-id=... \ -// --database-type=sqlite --database-url=auth.db +// --database-type=sqlite --database-url=auth.db --mcp-bearer=$TOKEN // // Which tools are exposed is declared at the proto layer via the // `(authorizer.common.v1.mcp_tool).exposed` option; the MCP server discovers -// them at startup. Today: GetMeta. As more public ops migrate into -// internal/service and get the mcp_tool annotation, they appear automatically. +// them at startup. +// +// Transport: STDIO ONLY. The MCP server has no auth/rate-limit interceptors +// of its own — the security model relies on the OS-level trust boundary of +// the subprocess. See internal/mcp/server.go's Server type comment. var mcpCmd = &cobra.Command{ Use: "mcp", Short: "Serve Authorizer's MCP tool surface over stdio", Long: "Exposes a subset of Authorizer's gRPC methods (those marked " + "(authorizer.common.v1.mcp_tool).exposed=true in proto) as MCP " + - "tools, suitable for use with Claude Code or any MCP-compatible host.", + "tools, suitable for use with Claude Code or any MCP-compatible " + + "host. Stdio is the only supported transport.", Run: runMCP, } func init() { + mcpCmd.Flags().StringVar(&mcpArgs.bearer, "mcp-bearer", "", + "Bearer token to attach to every outgoing gRPC call (carries the "+ + "user identity for tools like Profile / Permissions / Session). "+ + "When unset the MCP server runs anonymously; public tools (Meta) "+ + "still work but identity-bearing tools will fail authn.") RootCmd.AddCommand(mcpCmd) } @@ -44,22 +71,73 @@ func runMCP(_ *cobra.Command, _ []string) { // JSON-RPC framing on stdout. log := zerolog.New(os.Stderr).With().Timestamp().Logger() - // For the GetMeta-only vertical slice we don't need storage / token / - // memory store / events / email / sms. As more MCP-exposed tools come - // online (Phase 4+ migrations of ListMyPermissions, GetCurrentSession, - // GetUser(me)) wire them in following the same pattern as runRoot. + // Wire all subsystems an MCP-exposed tool might need. As more ops + // migrate into internal/service, this list stays the same — the + // service-provider dependencies don't change per op, only the methods + // on the provider do. + storageProvider, err := storage.New(&rootArgs.config, &storage.Dependencies{Log: &log}) + if err != nil { + log.Fatal().Err(err).Msg("failed to create storage provider") + } + memoryStoreProvider, err := memory_store.New(&rootArgs.config, &memory_store.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create memory store provider") + } + tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{ + Log: &log, + MemoryStoreProvider: memoryStoreProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create token provider") + } + emailProvider, err := email.New(&rootArgs.config, &email.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create email provider") + } + smsProvider, err := sms.New(&rootArgs.config, &sms.Dependencies{Log: &log}) + if err != nil { + log.Fatal().Err(err).Msg("failed to create sms provider") + } + auditProvider := audit.New(&audit.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + }) + eventsProvider, err := events.New(&rootArgs.config, &events.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create events provider") + } + + authorizationProvider, err := authorization.New( + &authorization.Config{CacheTTL: 0}, + &authorization.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + MemoryStoreProvider: memoryStoreProvider, + }, + ) + if err != nil { + log.Fatal().Err(err).Msg("failed to create authorization provider") + } + svc, err := service.New(&rootArgs.config, &service.Dependencies{ - Log: &log, - // nil-safe: methods that need these subsystems are not yet exposed - // as MCP tools. Each panics-on-nil call moved here would be caught - // by integration tests before reaching prod. - AuditProvider: audit.New(&audit.Dependencies{Log: &log}), - EmailProvider: nil, - EventsProvider: nil, - MemoryStoreProvider: nil, - SMSProvider: nil, - StorageProvider: nil, - TokenProvider: nil, + Log: &log, + AuditProvider: auditProvider, + AuthorizationProvider: authorizationProvider, + EmailProvider: emailProvider, + EventsProvider: eventsProvider, + MemoryStoreProvider: memoryStoreProvider, + SMSProvider: smsProvider, + StorageProvider: storageProvider, + TokenProvider: tokenProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create service provider") @@ -74,7 +152,11 @@ func runMCP(_ *cobra.Command, _ []string) { log.Fatal().Err(err).Msg("failed to create grpc server") } - mcpSrv, err := mcp.New(&log, grpcSrv.GRPCServer(), "authorizer", constants.VERSION) + mcpSrv, err := mcp.New(&log, grpcSrv.GRPCServer(), mcp.Options{ + Name: "authorizer", + Version: constants.VERSION, + Bearer: mcpArgs.bearer, + }) if err != nil { log.Fatal().Err(err).Msg("failed to create mcp server") } diff --git a/cmd/root.go b/cmd/root.go index 7c24be4c..6bcabd3a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -553,18 +553,18 @@ func runRoot(c *cobra.Command, args []string) { StorageProvider: storageProvider, }) - // Transport-agnostic service layer that hosts public-API operations - // (currently SignUp; more migrate over in subsequent phases). GraphQL, - // gRPC, and REST surfaces all delegate to this. + // Transport-agnostic service layer that hosts public-API operations. + // GraphQL, gRPC, and REST surfaces all delegate to this. serviceProvider, err := service.New(&rootArgs.config, &service.Dependencies{ - Log: &log, - AuditProvider: auditProvider, - EmailProvider: emailProvider, - EventsProvider: eventsProvider, - MemoryStoreProvider: memoryStoreProvider, - SMSProvider: smsProvider, - StorageProvider: storageProvider, - TokenProvider: tokenProvider, + Log: &log, + AuditProvider: auditProvider, + AuthorizationProvider: authorizationProvider, + EmailProvider: emailProvider, + EventsProvider: eventsProvider, + MemoryStoreProvider: memoryStoreProvider, + SMSProvider: smsProvider, + StorageProvider: storageProvider, + TokenProvider: tokenProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create service provider") diff --git a/gen/go/authorizer/v1/authorizer.pb.go b/gen/go/authorizer/v1/authorizer.pb.go index 2819abef..f37abc41 100644 --- a/gen/go/authorizer/v1/authorizer.pb.go +++ b/gen/go/authorizer/v1/authorizer.pb.go @@ -2520,7 +2520,7 @@ var file_authorizer_v1_authorizer_proto_rawDesc = []byte{ 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0xec, 0x11, 0x0a, 0x11, 0x41, 0x75, 0x74, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0xe6, 0x11, 0x0a, 0x11, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x68, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x52, @@ -2626,57 +2626,56 @@ var file_authorizer_v1_authorizer_proto_rawDesc = []byte{ 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x19, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, - 0x31, 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x66, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, + 0x31, 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x60, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x1c, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, - 0x3a, 0x01, 0x2a, 0x22, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x8a, 0x01, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, - 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, - 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x5f, 0x6a, 0x77, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, - 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, - 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x23, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, - 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x5b, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, - 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1a, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0a, 0x12, 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, - 0x74, 0x61, 0x12, 0x73, 0x0a, 0x0b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, 0x41, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, - 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, - 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x73, 0x65, 0x22, 0x16, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x3a, 0x01, 0x2a, 0x22, 0x0b, 0x2f, + 0x76, 0x31, 0x2f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x8a, 0x01, 0x0a, 0x10, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, + 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x25, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, + 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6a, 0x77, + 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0xa0, 0xb5, 0x18, 0x01, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x76, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x5b, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x1a, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x0a, 0x12, 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x73, 0x0a, 0x0b, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x21, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x1d, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, + 0x12, 0x0f, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, + 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, + 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/gen/go/authorizer/v1/authorizer_grpc.pb.go b/gen/go/authorizer/v1/authorizer_grpc.pb.go index 14bf3b49..bbbc25cc 100644 --- a/gen/go/authorizer/v1/authorizer_grpc.pb.go +++ b/gen/go/authorizer/v1/authorizer_grpc.pb.go @@ -89,6 +89,9 @@ type AuthorizerServiceClient interface { // Revoke invalidates a refresh token. Typed mirror of RFC 7009. Revoke(ctx context.Context, in *RevokeRequest, opts ...grpc.CallOption) (*RevokeResponse, error) // Session returns the AuthResponse bound to the caller's cookie/bearer. + // NOT exposed as an MCP tool — SessionResponse carries access_token, + // refresh_token, id_token, authenticator_secret, and recovery codes, + // none of which should land in an LLM transcript. (Security audit C1.) Session(ctx context.Context, in *SessionRequest, opts ...grpc.CallOption) (*SessionResponse, error) ValidateJwtToken(ctx context.Context, in *ValidateJwtTokenRequest, opts ...grpc.CallOption) (*ValidateJwtTokenResponse, error) ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error) @@ -326,6 +329,9 @@ type AuthorizerServiceServer interface { // Revoke invalidates a refresh token. Typed mirror of RFC 7009. Revoke(context.Context, *RevokeRequest) (*RevokeResponse, error) // Session returns the AuthResponse bound to the caller's cookie/bearer. + // NOT exposed as an MCP tool — SessionResponse carries access_token, + // refresh_token, id_token, authenticator_secret, and recovery codes, + // none of which should land in an LLM transcript. (Security audit C1.) Session(context.Context, *SessionRequest) (*SessionResponse, error) ValidateJwtToken(context.Context, *ValidateJwtTokenRequest) (*ValidateJwtTokenResponse, error) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) diff --git a/gen/openapi/authorizer.swagger.json b/gen/openapi/authorizer.swagger.json index 94c00861..31ff63a6 100644 --- a/gen/openapi/authorizer.swagger.json +++ b/gen/openapi/authorizer.swagger.json @@ -370,7 +370,7 @@ }, "/v1/session": { "post": { - "summary": "Session returns the AuthResponse bound to the caller's cookie/bearer.", + "summary": "Session returns the AuthResponse bound to the caller's cookie/bearer.\nNOT exposed as an MCP tool — SessionResponse carries access_token,\nrefresh_token, id_token, authenticator_secret, and recovery codes,\nnone of which should land in an LLM transcript. (Security audit C1.)", "operationId": "AuthorizerService_Session", "responses": { "200": { diff --git a/internal/cookie/cookie.go b/internal/cookie/cookie.go index 6cdd7fd0..9003a517 100644 --- a/internal/cookie/cookie.go +++ b/internal/cookie/cookie.go @@ -67,20 +67,45 @@ func BuildSessionCookies(hostname, sessionID string, appCookieSecure bool, sameS } } -// DeleteSession sets session cookies to expire +// DeleteSession sets session cookies to expire. func DeleteSession(gc *gin.Context, appCookieSecure bool, sameSite http.SameSite) { - secure := appCookieSecure - httpOnly := true - hostname := parsers.GetHost(gc) + for _, c := range BuildDeleteSessionCookies(parsers.GetHost(gc), appCookieSecure, sameSite) { + gc.SetSameSite(c.SameSite) + gc.SetCookie(c.Name, c.Value, c.MaxAge, c.Path, c.Domain, c.Secure, c.HttpOnly) + } +} + +// BuildDeleteSessionCookies returns the pair of zero-value, expired session +// cookies that cause browsers to delete the host-scoped and domain-scoped +// session cookies. Transport-agnostic mirror of DeleteSession. +func BuildDeleteSessionCookies(hostname string, appCookieSecure bool, sameSite http.SameSite) []*http.Cookie { host, _ := parsers.GetHostParts(hostname) domain := parsers.GetDomainName(hostname) if domain != "localhost" { domain = "." + domain } - - gc.SetSameSite(sameSite) - gc.SetCookie(constants.AppCookieName+"_session", "", -1, "/", host, secure, httpOnly) - gc.SetCookie(constants.AppCookieName+"_session_domain", "", -1, "/", domain, secure, httpOnly) + return []*http.Cookie{ + { + Name: constants.AppCookieName + "_session", + Value: "", + MaxAge: -1, + Path: "/", + Domain: host, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + { + Name: constants.AppCookieName + "_session_domain", + Value: "", + MaxAge: -1, + Path: "/", + Domain: domain, + Secure: appCookieSecure, + HttpOnly: true, + SameSite: sameSite, + }, + } } // GetSession gets the session cookie from context diff --git a/internal/graphql/logout.go b/internal/graphql/logout.go index 2c739539..83da52ae 100644 --- a/internal/graphql/logout.go +++ b/internal/graphql/logout.go @@ -3,55 +3,19 @@ package graphql import ( "context" - "github.com/authorizerdev/authorizer/internal/audit" - "github.com/authorizerdev/authorizer/internal/constants" - "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// Logout is the method to logout a user. -// Permissions: authenticated:* +// Logout delegates to the transport-agnostic service layer. +// Permissions: authenticated user. func (g *graphqlProvider) Logout(ctx context.Context) (*model.Response, error) { - log := g.Log.With().Str("func", "Logout").Logger() - gc, err := utils.GinContextFromContext(ctx) + gc, _ := utils.GinContextFromContext(ctx) + res, side, err := g.ServiceProvider.Logout(ctx, service.MetaFromGin(gc)) if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") return nil, err } - - tokenData, err := g.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user id from session or access token") - return nil, err - } - - sessionKey := tokenData.UserID - if tokenData.LoginMethod != "" { - sessionKey = tokenData.LoginMethod + ":" + tokenData.UserID - } - - if err = g.MemoryStoreProvider.DeleteUserSession(sessionKey, tokenData.Nonce); err != nil { - log.Debug().Err(err).Msg("Failed to delete user session") - return nil, err - } - cookie.DeleteSession(gc, g.Config.AppCookieSecure, cookie.ParseSameSite(g.Config.AppCookieSameSite)) - metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) - metrics.ActiveSessions.Dec() - g.AuditProvider.LogEvent(audit.Event{ - Action: constants.AuditLogoutEvent, - ActorID: tokenData.UserID, - ActorType: constants.AuditActorTypeUser, - ResourceType: constants.AuditResourceTypeSession, - ResourceID: tokenData.UserID, - IPAddress: utils.GetIP(gc.Request), - UserAgent: utils.GetUserAgent(gc.Request), - }) - - res := &model.Response{ - Message: "Logged out successfully", - } - + service.ApplyToGin(gc, side) return res, nil } diff --git a/internal/graphql/permissions.go b/internal/graphql/permissions.go index 07c66077..ad72308e 100644 --- a/internal/graphql/permissions.go +++ b/internal/graphql/permissions.go @@ -2,61 +2,16 @@ package graphql import ( "context" - "fmt" - "strings" - "github.com/authorizerdev/authorizer/internal/authorization" - "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// Permissions is the method to get all permissions for the authenticated user. -// Permissions: authorized user +// Permissions delegates to the transport-agnostic service layer. +// Permissions: authenticated user. func (g *graphqlProvider) Permissions(ctx context.Context) ([]*model.Permission, error) { - log := g.Log.With().Str("func", "Permissions").Logger() - gc, err := utils.GinContextFromContext(ctx) - if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") - return nil, err - } - - tokenData, err := g.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user from token") - return nil, fmt.Errorf("unauthorized") - } - - user, err := g.StorageProvider.GetUserByID(ctx, tokenData.UserID) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user by ID") - return nil, err - } - - var roles []string - if user.Roles != "" { - roles = strings.Split(user.Roles, ",") - } - - principal := &authorization.Principal{ - ID: user.ID, - Type: constants.PrincipalTypeUser, - Roles: roles, - } - - resourceScopes, err := g.AuthorizationProvider.GetPrincipalPermissions(ctx, principal) - if err != nil { - log.Debug().Err(err).Msg("Failed to get principal permissions") - return nil, err - } - - res := make([]*model.Permission, len(resourceScopes)) - for i, rs := range resourceScopes { - res[i] = &model.Permission{ - Resource: rs.Resource, - Scope: rs.Scope, - } - } - - return res, nil + gc, _ := utils.GinContextFromContext(ctx) + res, _, err := g.ServiceProvider.Permissions(ctx, service.MetaFromGin(gc)) + return res, err } diff --git a/internal/graphql/profile.go b/internal/graphql/profile.go index ebcb4926..d2aaecbe 100644 --- a/internal/graphql/profile.go +++ b/internal/graphql/profile.go @@ -4,29 +4,14 @@ import ( "context" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// Profile is the method to get the profile of a user. +// Profile delegates to the transport-agnostic service layer. +// Permissions: authenticated user. func (g *graphqlProvider) Profile(ctx context.Context) (*model.User, error) { - log := g.Log.With().Str("func", "Profile").Logger() - - gc, err := utils.GinContextFromContext(ctx) - if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") - return nil, err - } - tokenData, err := g.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user id from session or access token") - return nil, err - } - log = log.With().Str("user_id", tokenData.UserID).Logger() - user, err := g.StorageProvider.GetUserByID(ctx, tokenData.UserID) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user by id") - return nil, err - } - - return user.AsAPIUser(), nil + gc, _ := utils.GinContextFromContext(ctx) + res, _, err := g.ServiceProvider.Profile(ctx, service.MetaFromGin(gc)) + return res, err } diff --git a/internal/graphql/revoke.go b/internal/graphql/revoke.go index 3eb11c85..c9df7d56 100644 --- a/internal/graphql/revoke.go +++ b/internal/graphql/revoke.go @@ -2,66 +2,15 @@ package graphql import ( "context" - "errors" - "strings" - "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" ) -// Revoke is the method to revoke refresh token +// Revoke delegates to the transport-agnostic service layer. func (g *graphqlProvider) Revoke(ctx context.Context, params *model.OAuthRevokeRequest) (*model.Response, error) { - log := g.Log.With().Str("func", "Revoke").Logger() - token := strings.TrimSpace(params.RefreshToken) - if token == "" { - log.Error().Msg("Refresh token is empty") - return nil, errors.New("missing refresh token") - } - claims, err := g.TokenProvider.ParseJWTToken(token) - if err != nil { - log.Debug().Err(err).Msg("failed to parse jwt") - return nil, err - } - - userID, ok := claims["sub"].(string) - if !ok || userID == "" { - log.Debug().Msg("Invalid subject in token") - return nil, errors.New("invalid token") - } - loginMethod := claims["login_method"] - sessionToken := userID - if lm, ok := loginMethod.(string); ok && lm != "" { - sessionToken = lm + ":" + userID - } - - nonce, ok := claims["nonce"].(string) - if !ok || nonce == "" { - log.Debug().Msg("Invalid nonce in token") - return nil, errors.New("invalid token") - } - - existingToken, err := g.MemoryStoreProvider.GetUserSession(sessionToken, constants.TokenTypeRefreshToken+"_"+nonce) - if err != nil { - log.Debug().Err(err).Msg("Failed to get refresh token") - return nil, err - } - - if existingToken == "" { - log.Debug().Msg("Token not found") - return nil, errors.New("token not found") - } - - if existingToken != token { - log.Debug().Msg("Token does not match") - return nil, errors.New("token does not match") - } - - // Remove the token from the memory store - if err := g.MemoryStoreProvider.DeleteUserSession(sessionToken, nonce); err != nil { - log.Debug().Err(err).Msg("failed to delete user session") - return nil, err - } - return &model.Response{ - Message: "Token revoked", - }, nil + gc, _ := utils.GinContextFromContext(ctx) + res, _, err := g.ServiceProvider.Revoke(ctx, service.MetaFromGin(gc), params) + return res, err } diff --git a/internal/graphql/session.go b/internal/graphql/session.go index 2539f434..37cdaf82 100644 --- a/internal/graphql/session.go +++ b/internal/graphql/session.go @@ -2,167 +2,20 @@ package graphql import ( "context" - "errors" - "fmt" - "strings" - "time" - "github.com/google/uuid" - - "github.com/authorizerdev/authorizer/internal/constants" - "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/metrics" - "github.com/authorizerdev/authorizer/internal/parsers" - "github.com/authorizerdev/authorizer/internal/refs" - "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// Session is the method to get session. -// It also refreshes the session token. -// TODO allow validating with code and code verifier instead of cookie (PKCE flow) +// Session delegates to the transport-agnostic service layer; the rotated +// session cookie is applied back to the gin response from side-effects. func (g *graphqlProvider) Session(ctx context.Context, params *model.SessionQueryRequest) (*model.AuthResponse, error) { - log := g.Log.With().Str("func", "Session").Logger() - gc, err := utils.GinContextFromContext(ctx) - if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") - return nil, err - } - - sessionToken, err := cookie.GetSession(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get session token") - return nil, errors.New("unauthorized") - } - - // get session from cookie - claims, err := g.TokenProvider.ValidateBrowserSession(gc, sessionToken) - if err != nil { - log.Debug().Err(err).Msg("Failed to validate session token") - return nil, errors.New("unauthorized") - } - userID := claims.Subject - log = log.With().Str("user_id", userID).Logger() - user, err := g.StorageProvider.GetUserByID(ctx, userID) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user") - return nil, err - } - - // refresh token has "roles" as claim - claimRoleInterface := claims.Roles - claimRoles := []string{} - claimRoles = append(claimRoles, claimRoleInterface...) - - if params != nil && params.Roles != nil && len(params.Roles) > 0 { - for _, v := range params.Roles { - if !utils.StringSliceContains(claimRoles, v) { - log.Debug().Msg("User does not have required role") - return nil, fmt.Errorf(`unauthorized`) - } - } - } - - if params != nil { - if err := g.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointSession, user.ID, claimRoles, params.RequiredPermissions); err != nil { - return nil, err - } - } - - scope := []string{"openid", "email", "profile"} - if params != nil && params.Scope != nil && len(params.Scope) > 0 { - scope = params.Scope - } - - // OIDC authorize flow: if state is provided, consume the authorize state - // and prepare code/challenge data so the authorization code can be stored - // after token creation. This handles the case where the login UI auto-detects - // an existing session (e.g., prompt=login forced re-auth at /authorize but - // the session cookie is still valid for GraphQL queries). - code := "" - codeChallenge := "" - oidcNonce := "" - authorizeRedirectURI := "" - if params != nil && params.State != nil { - authorizeState, _ := g.MemoryStoreProvider.GetState(refs.StringValue(params.State)) - if authorizeState != "" { - authorizeStateSplit := strings.Split(authorizeState, "@@") - if len(authorizeStateSplit) > 1 { - code = authorizeStateSplit[0] - codeChallenge = authorizeStateSplit[1] - if len(authorizeStateSplit) > 2 { - oidcNonce = authorizeStateSplit[2] - } - if len(authorizeStateSplit) > 3 { - authorizeRedirectURI = authorizeStateSplit[3] - } - } - g.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) - } - } - - nonce := uuid.New().String() - hostname := parsers.GetHost(gc) - authToken, err := g.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ - User: user, - Nonce: nonce, - OIDCNonce: oidcNonce, - Code: code, - Roles: claimRoles, - Scope: scope, - LoginMethod: claims.LoginMethod, - HostName: hostname, - }) + gc, _ := utils.GinContextFromContext(ctx) + res, side, err := g.ServiceProvider.Session(ctx, service.MetaFromGin(gc), params) if err != nil { - log.Debug().Err(err).Msg("Failed to CreateAuthToken") return nil, err } - - // Store the authorization code state so /oauth/token can find it. - // The authorizeRedirectURI is already URL-encoded from the authorize state. - if code != "" { - if err := g.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { - log.Debug().Err(err).Msg("Failed to set code state") - return nil, err - } - } - - sessionKey := userID - if claims.LoginMethod != "" { - sessionKey = claims.LoginMethod + ":" + userID - } - - expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() - if expiresIn <= 0 { - expiresIn = 1 - } - - res := &model.AuthResponse{ - Message: `Session token refreshed`, - AccessToken: &authToken.AccessToken.Token, - ExpiresIn: &expiresIn, - IDToken: &authToken.IDToken.Token, - User: user.AsAPIUser(), - } - - // Establish the new session first, then revoke the old one. Doing both - // synchronously closes the window where a stolen pre-rotation token - // remains valid alongside the rotated one; doing "new then old" avoids - // any moment where the user has no valid session token. DeleteUserSession - // is in-memory or a single Redis DEL — failure is non-fatal (log and - // continue) since the new session is already live. - cookie.SetSession(gc, authToken.FingerPrintHash, g.Config.AppCookieSecure, cookie.ParseSameSite(g.Config.AppCookieSameSite)) - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt) - - if authToken.RefreshToken != nil { - res.RefreshToken = &authToken.RefreshToken.Token - g.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) - } - - if err := g.MemoryStoreProvider.DeleteUserSession(sessionKey, claims.Nonce); err != nil { - log.Warn().Err(err).Str("session_key", sessionKey).Msg("failed to delete old session during rollover") - } + service.ApplyToGin(gc, side) return res, nil } diff --git a/internal/graphql/validate_jwt_token.go b/internal/graphql/validate_jwt_token.go index 8b17ff50..8a815742 100644 --- a/internal/graphql/validate_jwt_token.go +++ b/internal/graphql/validate_jwt_token.go @@ -2,135 +2,15 @@ package graphql import ( "context" - "errors" - "fmt" - "github.com/golang-jwt/jwt/v4" - - "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/metrics" - "github.com/authorizerdev/authorizer/internal/parsers" - "github.com/authorizerdev/authorizer/internal/storage/schemas" - "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// ValidateJwtToken is used to validate a jwt token without its rotation -// this can be used at API level (backend) -// it can validate: -// access_token -// id_token -// refresh_token -// Permission: none +// ValidateJWTToken delegates to the transport-agnostic service layer. func (g *graphqlProvider) ValidateJWTToken(ctx context.Context, params *model.ValidateJWTTokenRequest) (*model.ValidateJWTTokenResponse, error) { - log := g.Log.With().Str("func", "ValidateJWTToken").Logger() - gc, err := utils.GinContextFromContext(ctx) - if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") - return nil, err - } - - tokenType := params.TokenType - if tokenType != constants.TokenTypeAccessToken && tokenType != constants.TokenTypeRefreshToken && tokenType != constants.TokenTypeIdentityToken { - log.Debug().Str("token_type", tokenType).Msg("Invalid token type") - return nil, errors.New("invalid token type") - } - - var claimRoles []string - var claims jwt.MapClaims - userID := "" - nonce := "" - - claims, err = g.TokenProvider.ParseJWTToken(params.Token) - if err != nil { - log.Debug().Err(err).Msg("Failed to parse jwt token") - return nil, err - } - sub, ok := claims["sub"].(string) - if !ok || sub == "" { - log.Debug().Msg("Invalid subject in token") - return nil, errors.New("invalid token") - } - userID = sub - - // access_token and refresh_token should be validated from session store as well - if tokenType == constants.TokenTypeAccessToken || tokenType == constants.TokenTypeRefreshToken { - nonceVal, ok := claims["nonce"].(string) - if !ok || nonceVal == "" { - log.Debug().Msg("Invalid nonce in token") - return nil, errors.New("invalid token") - } - nonce = nonceVal - loginMethod := claims["login_method"] - sessionKey := userID - if lm, ok := loginMethod.(string); ok && lm != "" { - sessionKey = lm + ":" + userID - } - token, err := g.MemoryStoreProvider.GetUserSession(sessionKey, tokenType+"_"+nonceVal) - if err != nil || token == "" { - log.Debug().Err(err).Msg("Failed to get token from session store") - return nil, errors.New("invalid token") - } - } - - hostname := parsers.GetHost(gc) - - // we cannot validate nonce in case of id_token as that token is not persisted in session store - if nonce != "" { - if ok, err := g.TokenProvider.ValidateJWTClaims(claims, &token.AuthTokenConfig{ - HostName: hostname, - Nonce: nonce, - User: &schemas.User{ - ID: userID, - }, - }); !ok || err != nil { - log.Debug().Err(err).Msg("Failed to validate jwt claims") - return nil, errors.New("invalid claims") - } - } else { - if ok, err := g.TokenProvider.ValidateJWTTokenWithoutNonce(claims, &token.AuthTokenConfig{ - HostName: hostname, - User: &schemas.User{ - ID: userID, - }, - }); !ok || err != nil { - log.Debug().Err(err).Msg("Failed to validate jwt claims") - return nil, errors.New("invalid claims") - } - } - - // Read roles from the configured claim key (used for id_token), falling - // back to the hardcoded "roles" claim that CreateAccessToken emits. This - // avoids a missing-roles principal when JWTRoleClaim is the default - // "role" (singular) but the token is an access_token (plural "roles"). - claimRolesInterface := claims[g.Config.JWTRoleClaim] - roleSlice := utils.ConvertInterfaceToSlice(claimRolesInterface) - if len(roleSlice) == 0 { - roleSlice = utils.ConvertInterfaceToSlice(claims["roles"]) - } - for _, v := range roleSlice { - roleStr, ok := v.(string) - if !ok || roleStr == "" { - log.Debug().Msg("Invalid role claim value") - return nil, errors.New("invalid claims") - } - claimRoles = append(claimRoles, roleStr) - } - - if len(params.Roles) > 0 { - for _, v := range params.Roles { - if !utils.StringSliceContains(claimRoles, v) { - log.Debug().Str("role", v).Msg("Role not found in claims") - return nil, fmt.Errorf(`unauthorized`) - } - } - } - if err := g.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointValidateJWTToken, userID, claimRoles, params.RequiredPermissions); err != nil { - return nil, err - } - return &model.ValidateJWTTokenResponse{ - IsValid: true, - Claims: claims, - }, nil + gc, _ := utils.GinContextFromContext(ctx) + res, _, err := g.ServiceProvider.ValidateJwtToken(ctx, service.MetaFromGin(gc), params) + return res, err } diff --git a/internal/graphql/validate_session.go b/internal/graphql/validate_session.go index f5686cad..06fcbd4e 100644 --- a/internal/graphql/validate_session.go +++ b/internal/graphql/validate_session.go @@ -2,72 +2,15 @@ package graphql import ( "context" - "errors" - "fmt" - "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/utils" ) -// ValidateSession is used to validate a cookie session without its rotation -// Permission: authorized:user +// ValidateSession delegates to the transport-agnostic service layer. func (g *graphqlProvider) ValidateSession(ctx context.Context, params *model.ValidateSessionRequest) (*model.ValidateSessionResponse, error) { - log := g.Log.With().Str("func", "ValidateSession").Logger() - gc, err := utils.GinContextFromContext(ctx) - if err != nil { - log.Debug().Err(err).Msg("Failed to get GinContext") - return nil, err - } - sessionToken := "" - if params != nil && params.Cookie != "" { - sessionToken = params.Cookie - } else { - sessionToken, err = cookie.GetSession(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get session token") - return nil, errors.New("unauthorized") - } - } - if sessionToken == "" { - sessionToken, err = cookie.GetSession(gc) - if err != nil { - log.Debug().Err(err).Msg("Failed to get session token") - return nil, errors.New("unauthorized") - } - } - claims, err := g.TokenProvider.ValidateBrowserSession(gc, sessionToken) - if err != nil { - log.Debug().Err(err).Msg("Failed to validate session") - return nil, errors.New("unauthorized") - } - userID := claims.Subject - log.Debug().Str("userID", userID).Msg("Validated session") - user, err := g.StorageProvider.GetUserByID(ctx, userID) - if err != nil { - log.Debug().Err(err).Msg("failed GetUserByID") - return nil, err - } - // refresh token has "roles" as claim - claimRoleInterface := claims.Roles - claimRoles := []string{} - claimRoles = append(claimRoles, claimRoleInterface...) - if params != nil && params.Roles != nil && len(params.Roles) > 0 { - for _, v := range params.Roles { - if !utils.StringSliceContains(claimRoles, v) { - log.Debug().Str("role", v).Msg("Role not found in claims") - return nil, fmt.Errorf(`unauthorized`) - } - } - } - if params != nil { - if err := g.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointValidateSession, user.ID, claimRoles, params.RequiredPermissions); err != nil { - return nil, err - } - } - return &model.ValidateSessionResponse{ - IsValid: true, - User: user.AsAPIUser(), - }, nil + gc, _ := utils.GinContextFromContext(ctx) + res, _, err := g.ServiceProvider.ValidateSession(ctx, service.MetaFromGin(gc), params) + return res, err } diff --git a/internal/grpcsrv/handlers/authorizer.go b/internal/grpcsrv/handlers/authorizer.go index dc95d9ed..4fe5950b 100644 --- a/internal/grpcsrv/handlers/authorizer.go +++ b/internal/grpcsrv/handlers/authorizer.go @@ -13,10 +13,11 @@ package handlers import ( "context" + authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" + "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/grpcsrv/transport" + "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/service" - - authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" ) // AuthorizerHandler implements authorizer.v1.AuthorizerService. The single @@ -28,6 +29,106 @@ type AuthorizerHandler struct { Service service.Provider } +// Revoke delegates to service.Revoke and projects the result. +func (h *AuthorizerHandler) Revoke(ctx context.Context, req *authorizerv1.RevokeRequest) (*authorizerv1.RevokeResponse, error) { + res, _, err := h.Service.Revoke(ctx, transport.MetaFromGRPC(ctx), &model.OAuthRevokeRequest{RefreshToken: req.RefreshToken}) + if err != nil { + return nil, err + } + return &authorizerv1.RevokeResponse{Message: res.Message}, nil +} + +// ValidateJwtToken delegates to service.ValidateJwtToken. The JWT claims +// map (free-form) is projected to AppData (which wraps Struct) to preserve +// the existing GraphQL semantics. +func (h *AuthorizerHandler) ValidateJwtToken(ctx context.Context, req *authorizerv1.ValidateJwtTokenRequest) (*authorizerv1.ValidateJwtTokenResponse, error) { + res, _, err := h.Service.ValidateJwtToken(ctx, transport.MetaFromGRPC(ctx), &model.ValidateJWTTokenRequest{ + TokenType: req.TokenType, + Token: req.Token, + Roles: req.Roles, + RequiredPermissions: protoToModelPermissions(req.RequiredPermissions), + }) + if err != nil { + return nil, err + } + return &authorizerv1.ValidateJwtTokenResponse{ + IsValid: res.IsValid, + Claims: claimsToAppData(res.Claims), + }, nil +} + +// ValidateSession delegates to service.ValidateSession. +func (h *AuthorizerHandler) ValidateSession(ctx context.Context, req *authorizerv1.ValidateSessionRequest) (*authorizerv1.ValidateSessionResponse, error) { + res, _, err := h.Service.ValidateSession(ctx, transport.MetaFromGRPC(ctx), &model.ValidateSessionRequest{ + Cookie: req.Cookie, + Roles: req.Roles, + RequiredPermissions: protoToModelPermissions(req.RequiredPermissions), + }) + if err != nil { + return nil, err + } + return &authorizerv1.ValidateSessionResponse{ + IsValid: res.IsValid, + User: projectUser(res.User), + }, nil +} + +// Session delegates to service.Session, applies the rotated session cookie +// to the outgoing stream, and projects the AuthResponse. SessionResponse +// carries credentials and is intentionally NOT MCP-exposed (audit C1). +func (h *AuthorizerHandler) Session(ctx context.Context, req *authorizerv1.SessionRequest) (*authorizerv1.SessionResponse, error) { + res, side, err := h.Service.Session(ctx, transport.MetaFromGRPC(ctx), &model.SessionQueryRequest{ + Roles: req.Roles, + Scope: req.Scope, + State: refs.NewStringRef(req.State), + RequiredPermissions: protoToModelPermissions(req.RequiredPermissions), + }) + if err != nil { + return nil, err + } + _ = transport.ApplyToGRPC(ctx, side) + return &authorizerv1.SessionResponse{Auth: projectAuthResponse(res)}, nil +} + +// Profile delegates to service.Profile and projects the result into the +// proto ProfileResponse. Requires session/bearer auth (handled inside the +// service via TokenProvider.GetUserIDFromSessionOrAccessToken). +func (h *AuthorizerHandler) Profile(ctx context.Context, _ *authorizerv1.ProfileRequest) (*authorizerv1.ProfileResponse, error) { + u, _, err := h.Service.Profile(ctx, transport.MetaFromGRPC(ctx)) + if err != nil { + return nil, err + } + return &authorizerv1.ProfileResponse{User: projectUser(u)}, nil +} + +// Permissions delegates to service.Permissions and projects the result into +// the proto PermissionsResponse. +func (h *AuthorizerHandler) Permissions(ctx context.Context, _ *authorizerv1.PermissionsRequest) (*authorizerv1.PermissionsResponse, error) { + perms, _, err := h.Service.Permissions(ctx, transport.MetaFromGRPC(ctx)) + if err != nil { + return nil, err + } + out := make([]*authorizerv1.Permission, len(perms)) + for i, p := range perms { + out[i] = &authorizerv1.Permission{Resource: p.Resource, Scope: p.Scope} + } + return &authorizerv1.PermissionsResponse{Permissions: out}, nil +} + +// Logout delegates to service.Logout, applies any cookie side-effects to +// the outgoing gRPC stream (grpc-gateway lifts them to Set-Cookie when +// the call came in via REST), then returns the typed response. +func (h *AuthorizerHandler) Logout(ctx context.Context, _ *authorizerv1.LogoutRequest) (*authorizerv1.LogoutResponse, error) { + res, side, err := h.Service.Logout(ctx, transport.MetaFromGRPC(ctx)) + if err != nil { + return nil, err + } + // Best-effort: cookie application is out-of-band; a SendHeader failure + // degrades to "user has to re-auth" rather than failing the request. + _ = transport.ApplyToGRPC(ctx, side) + return &authorizerv1.LogoutResponse{Message: res.Message}, nil +} + // Meta delegates to service.Meta and projects the GraphQL Meta model into // the proto MetaResponse. func (h *AuthorizerHandler) Meta(ctx context.Context, _ *authorizerv1.MetaRequest) (*authorizerv1.MetaResponse, error) { diff --git a/internal/grpcsrv/handlers/project.go b/internal/grpcsrv/handlers/project.go new file mode 100644 index 00000000..6d4dd4f8 --- /dev/null +++ b/internal/grpcsrv/handlers/project.go @@ -0,0 +1,157 @@ +// Package-internal projection helpers: convert the GraphQL/storage model +// types returned by service.* into the proto wire types. Centralised here +// so each handler can stay focused on its delegation pattern. +package handlers + +import ( + "encoding/json" + + "google.golang.org/protobuf/types/known/structpb" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + + commonv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/common/v1" + authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" +) + +// projectUser converts the GraphQL User model into the proto User message. +// Optional fields (nil pointers / nil maps) collapse to zero values; the +// gateway's UseProtoNames + EmitUnpopulated configuration makes them +// visible to REST clients regardless. +func projectUser(u *model.User) *authorizerv1.User { + if u == nil { + return nil + } + out := &authorizerv1.User{ + Id: u.ID, + Email: refs.StringValue(u.Email), + EmailVerified: u.EmailVerified, + SignupMethods: u.SignupMethods, + GivenName: refs.StringValue(u.GivenName), + FamilyName: refs.StringValue(u.FamilyName), + MiddleName: refs.StringValue(u.MiddleName), + Nickname: refs.StringValue(u.Nickname), + PreferredUsername: refs.StringValue(u.PreferredUsername), + Gender: refs.StringValue(u.Gender), + Birthdate: refs.StringValue(u.Birthdate), + PhoneNumber: refs.StringValue(u.PhoneNumber), + PhoneNumberVerified: u.PhoneNumberVerified, + Picture: refs.StringValue(u.Picture), + Roles: u.Roles, + CreatedAt: refs.Int64Value(u.CreatedAt), + UpdatedAt: refs.Int64Value(u.UpdatedAt), + RevokedTimestamp: refs.Int64Value(u.RevokedTimestamp), + IsMultiFactorAuthEnabled: refs.BoolValue(u.IsMultiFactorAuthEnabled), + } + if u.AppData != nil { + out.AppData = projectAppData(u.AppData) + } + return out +} + +// projectAuthResponse converts the GraphQL AuthResponse model into the +// proto AuthResponse. Used by the Session handler; the credential fields +// (tokens, authenticator secret/recovery codes) are passed through to gRPC +// + REST callers but the proto annotation on Session intentionally keeps +// it OFF the MCP surface (security audit C1). +func projectAuthResponse(a *model.AuthResponse) *authorizerv1.AuthResponse { + if a == nil { + return nil + } + return &authorizerv1.AuthResponse{ + Message: a.Message, + ShouldShowEmailOtpScreen: refs.BoolValue(a.ShouldShowEmailOtpScreen), + ShouldShowMobileOtpScreen: refs.BoolValue(a.ShouldShowMobileOtpScreen), + ShouldShowTotpScreen: refs.BoolValue(a.ShouldShowTotpScreen), + AccessToken: refs.StringValue(a.AccessToken), + IdToken: refs.StringValue(a.IDToken), + RefreshToken: refs.StringValue(a.RefreshToken), + ExpiresIn: refs.Int64Value(a.ExpiresIn), + User: projectUser(a.User), + AuthenticatorScannerImage: refs.StringValue(a.AuthenticatorScannerImage), + AuthenticatorSecret: refs.StringValue(a.AuthenticatorSecret), + AuthenticatorRecoveryCodes: derefStringSlice(a.AuthenticatorRecoveryCodes), + } +} + +// projectAppData converts the free-form GraphQL Map into the proto AppData +// (which wraps a google.protobuf.Struct). The conversion uses JSON as the +// lingua franca, matching how AppData is persisted today. +func projectAppData(m map[string]any) *commonv1.AppData { + if len(m) == 0 { + return nil + } + // Round-trip via JSON so anything Struct can't represent natively + // (e.g. nested numbers > int64) gets surfaced consistently. + b, err := json.Marshal(m) + if err != nil { + return nil + } + var generic map[string]any + if err := json.Unmarshal(b, &generic); err != nil { + return nil + } + s, err := structpb.NewStruct(generic) + if err != nil { + return nil + } + return &commonv1.AppData{Value: s} +} + +// derefStringSlice converts []*string (GraphQL's nullable string list shape) +// into []string, dropping nil entries. +func derefStringSlice(in []*string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + for _, s := range in { + if s != nil { + out = append(out, *s) + } + } + return out +} + +// protoToModelPermissions converts the proto PermissionInput repeated field +// into the GraphQL model.PermissionInput slice used by service methods. +func protoToModelPermissions(in []*authorizerv1.PermissionInput) []*model.PermissionInput { + if len(in) == 0 { + return nil + } + out := make([]*model.PermissionInput, 0, len(in)) + for _, p := range in { + if p == nil { + continue + } + out = append(out, &model.PermissionInput{ + Resource: p.Resource, + Scope: p.Scope, + }) + } + return out +} + +// claimsToAppData converts a free-form JWT claims map (interface{}-valued) +// into the proto AppData wrapper around google.protobuf.Struct. JSON is the +// lingua franca — matches projectAppData's strategy and tolerates anything +// the underlying JWT library produces. +func claimsToAppData(claims map[string]any) *commonv1.AppData { + if len(claims) == 0 { + return nil + } + b, err := json.Marshal(claims) + if err != nil { + return nil + } + var generic map[string]any + if err := json.Unmarshal(b, &generic); err != nil { + return nil + } + s, err := structpb.NewStruct(generic) + if err != nil { + return nil + } + return &commonv1.AppData{Value: s} +} diff --git a/internal/grpcsrv/interceptors/interceptors_test.go b/internal/grpcsrv/interceptors/interceptors_test.go index 007ba22a..d5b02e84 100644 --- a/internal/grpcsrv/interceptors/interceptors_test.go +++ b/internal/grpcsrv/interceptors/interceptors_test.go @@ -35,9 +35,31 @@ func TestRecovery_TurnsPanicIntoInternal(t *testing.T) { require.True(t, ok, "expected a gRPC status error") assert.Equal(t, codes.Internal, st.Code()) assert.Equal(t, "internal server error", st.Message(), "panic detail must not leak to clients") - // The stack stays server-side. - assert.Contains(t, buf.String(), "panicked") - assert.Contains(t, buf.String(), "kaboom") + out := buf.String() + // The stack + type get logged. The recovered VALUE does NOT — security + // audit H2: panic values can carry credentials (Password / RefreshToken + // / OTP / AdminSecret) that must not reach the log stream. + assert.Contains(t, out, "panicked") + assert.Contains(t, out, `"panic_type":"string"`) + assert.NotContains(t, out, "kaboom", + "the panic VALUE must not appear in logs; only its type — see H2") +} + +// TestRecovery_DoesNotLogCredentialBearingPanicValue is the regression test +// for security audit H2: a handler that panics with a value containing +// credentials must NOT have those credentials written to the log stream. +func TestRecovery_DoesNotLogCredentialBearingPanicValue(t *testing.T) { + var buf bytes.Buffer + log := zerolog.New(&buf) + r := Recovery(&log) + _, _ = r(context.Background(), nil, info("/svc/X"), func(_ context.Context, _ any) (any, error) { + // Simulate a handler panicking with a credential-bearing value. + panic("password=hunter2 token=secretXYZ") + }) + out := buf.String() + assert.NotContains(t, out, "hunter2", "panic value must not reach logs") + assert.NotContains(t, out, "secretXYZ", "panic value must not reach logs") + assert.Contains(t, out, `"panic_type":"string"`, "type should still be logged for triage") } func TestRecovery_PassesNormalErrorsThrough(t *testing.T) { diff --git a/internal/grpcsrv/interceptors/recovery.go b/internal/grpcsrv/interceptors/recovery.go index 9a2dfa37..01e73805 100644 --- a/internal/grpcsrv/interceptors/recovery.go +++ b/internal/grpcsrv/interceptors/recovery.go @@ -10,6 +10,7 @@ package interceptors import ( "context" + "fmt" "runtime/debug" "github.com/rs/zerolog" @@ -21,13 +22,20 @@ import ( // Recovery returns a unary interceptor that converts handler panics into a // codes.Internal error and logs the stack at error level. The stack stays // server-side — clients only see a generic "internal error" message. +// +// Security: the panic value is logged as TYPE only, never its full content. +// A handler can panic with a request struct that includes credentials +// (Password, RefreshToken, OTP, AdminSecret, ...); dumping the value via +// .Interface() would write those credentials to the log stream verbatim. +// Logging just the type lets ops correlate the panic with the stack without +// exposing payload fields. (Security audit finding H2.) func Recovery(log *zerolog.Logger) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { defer func() { if r := recover(); r != nil { log.Error(). Str("method", info.FullMethod). - Interface("panic", r). + Str("panic_type", fmt.Sprintf("%T", r)). Bytes("stack", debug.Stack()). Msg("gRPC handler panicked") err = status.Error(codes.Internal, "internal server error") diff --git a/internal/integration_tests/grpc_surface_test.go b/internal/integration_tests/grpc_surface_test.go index fb39b701..99ed4c8a 100644 --- a/internal/integration_tests/grpc_surface_test.go +++ b/internal/integration_tests/grpc_surface_test.go @@ -52,10 +52,9 @@ func bootGRPCBufconn(t *testing.T) *grpc.ClientConn { // TestAuthorizerServiceStubsReturnUnimplemented locks down the contract for // every not-yet-migrated method on the consolidated AuthorizerService. -// Today Meta is the only real implementation; the remaining 18 methods -// return codes.Unimplemented. As each one gets migrated out of -// internal/graphql into internal/service, the corresponding sub-test here -// will start returning OK and the case can be moved to a happy-path test. +// Real today: Meta, Profile, Permissions, Logout, Revoke, Session, +// ValidateJwtToken, ValidateSession (covered elsewhere). As each remaining +// method's handler is wired up, drop its entry below. func TestAuthorizerServiceStubsReturnUnimplemented(t *testing.T) { conn := bootGRPCBufconn(t) ctx := context.Background() @@ -71,10 +70,6 @@ func TestAuthorizerServiceStubsReturnUnimplemented(t *testing.T) { _, err := c.Login(c0, &authorizerv1.LoginRequest{Password: "p"}) return err }, - "Logout": func(c0 context.Context) error { - _, err := c.Logout(c0, &authorizerv1.LogoutRequest{}) - return err - }, "MagicLinkLogin": func(c0 context.Context) error { _, err := c.MagicLinkLogin(c0, &authorizerv1.MagicLinkLoginRequest{Email: "x@example.com"}) return err @@ -103,10 +98,6 @@ func TestAuthorizerServiceStubsReturnUnimplemented(t *testing.T) { _, err := c.ResetPassword(c0, &authorizerv1.ResetPasswordRequest{Token: "t", Password: "p", ConfirmPassword: "p"}) return err }, - "Profile": func(c0 context.Context) error { - _, err := c.Profile(c0, &authorizerv1.ProfileRequest{}) - return err - }, "UpdateProfile": func(c0 context.Context) error { _, err := c.UpdateProfile(c0, &authorizerv1.UpdateProfileRequest{}) return err @@ -115,26 +106,6 @@ func TestAuthorizerServiceStubsReturnUnimplemented(t *testing.T) { _, err := c.DeactivateAccount(c0, &authorizerv1.DeactivateAccountRequest{}) return err }, - "Revoke": func(c0 context.Context) error { - _, err := c.Revoke(c0, &authorizerv1.RevokeRequest{RefreshToken: "t"}) - return err - }, - "Session": func(c0 context.Context) error { - _, err := c.Session(c0, &authorizerv1.SessionRequest{}) - return err - }, - "ValidateJwtToken": func(c0 context.Context) error { - _, err := c.ValidateJwtToken(c0, &authorizerv1.ValidateJwtTokenRequest{TokenType: "access_token", Token: "t"}) - return err - }, - "ValidateSession": func(c0 context.Context) error { - _, err := c.ValidateSession(c0, &authorizerv1.ValidateSessionRequest{Cookie: "c"}) - return err - }, - "Permissions": func(c0 context.Context) error { - _, err := c.Permissions(c0, &authorizerv1.PermissionsRequest{}) - return err - }, } for name, fn := range cases { diff --git a/internal/integration_tests/mcp_stubs_test.go b/internal/integration_tests/mcp_stubs_test.go index 3105d608..d1589b35 100644 --- a/internal/integration_tests/mcp_stubs_test.go +++ b/internal/integration_tests/mcp_stubs_test.go @@ -14,13 +14,19 @@ import ( "github.com/authorizerdev/authorizer/internal/service" ) -// TestMCPStubReturnsError exercises the "MCP tool exposed in proto but its -// underlying gRPC handler is still a stub" path. This is the current state -// of get_user, get_current_session, and list_my_permissions: they appear in -// tools/list (proven by TestMCPListAndCallGetMeta) and a call must surface -// the underlying codes.Unimplemented as a tool error rather than silently -// succeeding or panicking. -func TestMCPStubReturnsError(t *testing.T) { +// TestMCPToolErrorSurfacesAsIsErrorResult verifies that when the underlying +// gRPC handler returns a non-OK status, the MCP server surfaces it as a +// CallToolResult{IsError:true} (tool-level error) rather than as a +// JSON-RPC protocol error. This is the MCP-spec way to give the LLM +// actionable text it can react to (vs aborting the whole exchange). +// +// We exercise this by calling `permissions` without a bearer token; the +// underlying Permissions handler returns "unauthorized" from +// TokenProvider.GetUserIDFromSessionOrAccessToken. This is also a check +// that the MCP-side auth gating works (security audit H1): calling an +// identity-bearing tool with no Authorization metadata produces a clean, +// auditable error. +func TestMCPToolErrorSurfacesAsIsErrorResult(t *testing.T) { cfg := getTestConfig() cfg.ClientID = "test-client" log := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() @@ -29,7 +35,9 @@ func TestMCPStubReturnsError(t *testing.T) { require.NoError(t, err) grpcSrv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{Log: &log, Config: cfg, ServiceProvider: svc}) require.NoError(t, err) - mcpSrv, err := authmcp.New(&log, grpcSrv.GRPCServer(), "authorizer-test", "v0") + // Note: opts.Bearer deliberately empty — the server runs anonymously, + // so identity-bearing tools must fail with a clean tool error. + mcpSrv, err := authmcp.New(&log, grpcSrv.GRPCServer(), authmcp.Options{Name: "authorizer-test", Version: "v0"}) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) @@ -44,21 +52,14 @@ func TestMCPStubReturnsError(t *testing.T) { require.NoError(t, err) defer clientSession.Close() - // permissions is exposed via the proto annotation but its - // AuthorizerService.Permissions handler is a stub returning codes.Unimplemented. - // The MCP server must surface this as a CallToolResult{IsError:true} - // (tool-level error) rather than a JSON-RPC protocol error — so the - // LLM gets actionable text and can react / try a different tool. res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ Name: "permissions", Arguments: map[string]any{}, }) require.NoError(t, err, "tool execution errors must NOT surface as protocol errors") require.NotNil(t, res) - assert.True(t, res.IsError, "stubbed tool must return IsError=true") + assert.True(t, res.IsError, "anonymous call to identity-bearing tool must return IsError=true") require.NotEmpty(t, res.Content) - text, ok := res.Content[0].(*mcp.TextContent) + _, ok := res.Content[0].(*mcp.TextContent) require.True(t, ok, "error content should be text") - assert.Contains(t, text.Text, "Unimplemented", - "the underlying gRPC Unimplemented code should be reflected in the MCP error text") } diff --git a/internal/integration_tests/mcp_test.go b/internal/integration_tests/mcp_test.go index 712fde37..17a634eb 100644 --- a/internal/integration_tests/mcp_test.go +++ b/internal/integration_tests/mcp_test.go @@ -34,7 +34,7 @@ func TestMCPListAndCallMeta(t *testing.T) { }) require.NoError(t, err) - mcpSrv, err := authmcp.New(&log, grpcSrv.GRPCServer(), "authorizer-test", "v0") + mcpSrv, err := authmcp.New(&log, grpcSrv.GRPCServer(), authmcp.Options{Name: "authorizer-test", Version: "v0"}) require.NoError(t, err) // Wire client ↔ server via in-memory transports (no stdio). @@ -50,17 +50,21 @@ func TestMCPListAndCallMeta(t *testing.T) { require.NoError(t, err) defer clientSession.Close() - // tools/list — should include the four proto-annotated MCP tools: - // meta, profile, session, permissions. + // tools/list — should include the three proto-annotated MCP tools: + // meta, profile, permissions. (Session was DROPPED from MCP exposure + // in the security pass; its response carries credentials that + // shouldn't land in an LLM transcript — audit finding C1.) list, err := clientSession.ListTools(ctx, nil) require.NoError(t, err) gotNames := map[string]bool{} for _, tool := range list.Tools { gotNames[tool.Name] = true } - for _, want := range []string{"meta", "profile", "session", "permissions"} { + for _, want := range []string{"meta", "profile", "permissions"} { require.True(t, gotNames[want], "expected MCP tool %q to be exposed; got %v", want, gotNames) } + require.False(t, gotNames["session"], + "session tool MUST NOT be exposed via MCP (carries access_token/refresh_token/etc.)") // tools/call meta — should invoke AuthorizerService.Meta and return JSON // wrapped in the per-RPC MetaResponse shape. diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index 3890e329..2b16391b 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -208,16 +208,17 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { StorageProvider: storageProvider, }) - // Transport-agnostic service layer for migrated public ops (SignUp etc.). + // Transport-agnostic service layer for migrated public ops. serviceProvider, err := service.New(cfg, &service.Dependencies{ - Log: &logger, - AuditProvider: auditProvider, - EmailProvider: emailProvider, - EventsProvider: eventsProvider, - MemoryStoreProvider: memoryStoreProvider, - SMSProvider: smsProvider, - StorageProvider: storageProvider, - TokenProvider: tokenProvider, + Log: &logger, + AuditProvider: auditProvider, + AuthorizationProvider: authzProvider, + EmailProvider: emailProvider, + EventsProvider: eventsProvider, + MemoryStoreProvider: memoryStoreProvider, + SMSProvider: smsProvider, + StorageProvider: storageProvider, + TokenProvider: tokenProvider, }) require.NoError(t, err) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9bd5c0c9..8d151463 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1,3 +1,6 @@ +// Package mcp serves a curated subset of Authorizer's gRPC methods to +// LLM clients via the Model Context Protocol. Stdio is the ONLY supported +// transport — see the deliberate design note on Server below. package mcp import ( @@ -11,6 +14,7 @@ import ( "github.com/rs/zerolog" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -19,15 +23,44 @@ import ( const bufSize = 1 << 20 -// Server wraps an MCP server that bridges to an in-process gRPC server. The -// gRPC server is the source of truth for which tools exist (via the -// `mcp_tool` proto annotation); we never hand-register tools here. +// Server wraps an MCP server that bridges to an in-process gRPC server. +// +// Design constraint: stdio is the ONLY supported transport. The MCP server +// has no auth/rate-limit/audit interceptors of its own — it relies entirely +// on the OS-level trust boundary of the subprocess (Claude Code spawns +// `authorizer mcp` as a child; only that process can write to its stdin). +// Exposing the MCP server over TCP / HTTP / SSE would invalidate that +// assumption and is intentionally NOT implementable: there is no RunHTTP / +// RunTCP / RunSSE method, and adding one without first implementing an +// auth layer is a security regression. The stdio-only contract is also +// enforced by TestServer_StdioOnly. type Server struct { log *zerolog.Logger mcpSrv *mcp.Server gwConn *grpc.ClientConn lis *bufconn.Listener grpcSrv *grpc.Server + + // bearer is the value of the Authorization header stamped on every + // outgoing gRPC call. Set via Options.Bearer at construction time + // (the cmd/mcp.go subcommand exposes --mcp-bearer). When empty, calls + // flow without auth — fine for public methods like Meta, but anything + // requiring identity (Profile, Permissions, ...) will see an empty + // caller and return whatever its handler does in that case. + bearer string +} + +// Options configures the MCP server. +type Options struct { + // Name is the MCP server's reported implementation name. + Name string + // Version is the MCP server's reported implementation version. + Version string + // Bearer, when set, is propagated as `Authorization: Bearer ` + // metadata on every gRPC dispatch. This is how MCP-side identity + // reaches the gRPC handlers (security audit H1). The bearer should be + // a token issued for the user the MCP host is acting on behalf of. + Bearer string } // New builds an MCP server that exposes every gRPC method on `grpcSrv` @@ -35,12 +68,15 @@ type Server struct { // The gRPC server is served over an in-process bufconn — same pattern as // the REST gateway — so MCP tool invocations become local method calls with // no extra network hop. -func New(log *zerolog.Logger, grpcSrv *grpc.Server, name, version string) (*Server, error) { +func New(log *zerolog.Logger, grpcSrv *grpc.Server, opts Options) (*Server, error) { bindings, err := Scan(grpcSrv) if err != nil { return nil, fmt.Errorf("mcp: scan tools: %w", err) } - log.Info().Int("tools", len(bindings)).Msg("MCP: discovered tools from proto annotations") + log.Info(). + Int("tools", len(bindings)). + Bool("authenticated", opts.Bearer != ""). + Msg("MCP: discovered tools from proto annotations") // Same bufconn dance as the REST gateway. lis := bufconn.Listen(bufSize) @@ -56,21 +92,22 @@ func New(log *zerolog.Logger, grpcSrv *grpc.Server, name, version string) (*Serv } mcpSrv := mcp.NewServer(&mcp.Implementation{ - Name: name, - Version: version, + Name: opts.Name, + Version: opts.Version, }, nil) - for _, b := range bindings { - registerTool(log, mcpSrv, conn, b) - } - - return &Server{ + s := &Server{ log: log, mcpSrv: mcpSrv, gwConn: conn, lis: lis, grpcSrv: grpcSrv, - }, nil + bearer: opts.Bearer, + } + for _, b := range bindings { + s.registerTool(b) + } + return s, nil } // MCPServer exposes the underlying *mcp.Server. Used by tests to drive the @@ -79,6 +116,9 @@ func (s *Server) MCPServer() *mcp.Server { return s.mcpSrv } // RunStdio serves MCP over stdio (the default Claude Code transport). Blocks // until ctx is cancelled or the client disconnects. +// +// This is the only `Run*` method on the Server. See the type comment for why +// adding a non-stdio transport is intentionally a code-level non-feature. func (s *Server) RunStdio(ctx context.Context) error { defer s.cleanup() return s.mcpSrv.Run(ctx, &mcp.StdioTransport{}) @@ -89,12 +129,22 @@ func (s *Server) cleanup() { _ = s.lis.Close() } +// stampAuth attaches the configured bearer to the outgoing gRPC call. A +// no-op when the bearer is unset. This is the bridge that lets gRPC handlers +// see "who is calling" when invoked from MCP (security audit H1). +func (s *Server) stampAuth(ctx context.Context) context.Context { + if s.bearer == "" { + return ctx + } + return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+s.bearer) +} + // registerTool wires one ToolBinding into the MCP server. The handler: -// 1. Constructs a fresh proto.Message of the right type via dynamicpb -// 2. Unmarshals JSON args into it -// 3. Invokes the gRPC method via grpc.ClientConn.Invoke -// 4. Marshals the response back to JSON for the MCP client -func registerTool(log *zerolog.Logger, srv *mcp.Server, conn *grpc.ClientConn, b ToolBinding) { +// 1. Constructs a fresh proto.Message of the right type via dynamicpb +// 2. Unmarshals JSON args into it +// 3. Invokes the gRPC method via grpc.ClientConn.Invoke (with bearer) +// 4. Marshals the response back to JSON for the MCP client +func (s *Server) registerTool(b ToolBinding) { schema := schemaForMessage(b.InputDescriptor) tool := &mcp.Tool{ Name: b.Name, @@ -106,7 +156,7 @@ func registerTool(log *zerolog.Logger, srv *mcp.Server, conn *grpc.ClientConn, b tool.Annotations = &mcp.ToolAnnotations{DestructiveHint: ptrTrue()} } - srv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.mcpSrv.AddTool(tool, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Build a dynamic proto.Message for the request, then unmarshal JSON. reqMsg := dynamicpb.NewMessage(b.InputDescriptor) if len(req.Params.Arguments) > 0 && !isJSONNull(req.Params.Arguments) { @@ -118,8 +168,8 @@ func registerTool(log *zerolog.Logger, srv *mcp.Server, conn *grpc.ClientConn, b } respMsg := dynamicpb.NewMessage(b.OutputDescriptor) - if err := conn.Invoke(ctx, b.FullMethod, reqMsg, respMsg); err != nil { - log.Debug().Err(err).Str("tool", b.Name).Str("method", b.FullMethod).Msg("MCP tool invocation failed") + if err := s.gwConn.Invoke(s.stampAuth(ctx), b.FullMethod, reqMsg, respMsg); err != nil { + s.log.Debug().Err(err).Str("tool", b.Name).Str("method", b.FullMethod).Msg("MCP tool invocation failed") // gRPC errors (Unimplemented, PermissionDenied, NotFound, ...) // become CallToolResult{IsError: true} with the gRPC status // message as the content. The MCP host shows this to the LLM diff --git a/internal/mcp/transport_test.go b/internal/mcp/transport_test.go new file mode 100644 index 00000000..5941918f --- /dev/null +++ b/internal/mcp/transport_test.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "reflect" + "strings" + "testing" +) + +// TestServer_StdioOnly is a guard against accidentally adding a non-stdio +// transport to the MCP server. Stdio is the only supported transport — the +// security model relies on the OS-level trust boundary of the subprocess +// (Claude Code spawns `authorizer mcp` as a child; only that process can +// write to its stdin). Exposing MCP over TCP/HTTP/SSE without an auth +// interceptor would be a security regression, so this test fails the build +// if anyone adds RunHTTP / RunTCP / RunSSE / Listen* / Serve* etc. +// +// To deliberately add a new transport: implement an auth+rate-limit +// interceptor for MCP first, then update this test's allow-list. +func TestServer_StdioOnly(t *testing.T) { + allowed := map[string]struct{}{ + "RunStdio": {}, + "MCPServer": {}, // test accessor — not a transport + } + t.Logf("MCP Server exported methods allow-list: %v (anything outside this set indicates a new transport)", allowed) + + st := reflect.TypeOf((*Server)(nil)) + for i := 0; i < st.NumMethod(); i++ { + name := st.Method(i).Name + if _, ok := allowed[name]; ok { + continue + } + // Heuristic: any method whose name suggests serving / running / + // listening over a different transport is a red flag. + lower := strings.ToLower(name) + for _, banned := range []string{"http", "tcp", "sse", "websocket", "listen", "serve", "run"} { + if strings.Contains(lower, banned) { + t.Errorf("disallowed transport method %q on *Server: stdio is the only supported MCP transport. "+ + "Adding a network transport requires an MCP-side auth interceptor first; see Server type comment.", name) + } + } + } +} diff --git a/internal/service/logout.go b/internal/service/logout.go new file mode 100644 index 00000000..be99b6cf --- /dev/null +++ b/internal/service/logout.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" +) + +// Logout ends the caller's current session: drops the memory-store session +// entry, emits expired Set-Cookie headers, records audit + metrics events. +// Transport-agnostic port of graphqlProvider.Logout. +// +// Permissions: authenticated user. +func (p *provider) Logout(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "Logout").Logger() + side := &ResponseSideEffects{} + + gc := &gin.Context{Request: meta.Request} + tokenData, err := p.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, err + } + + sessionKey := tokenData.UserID + if tokenData.LoginMethod != "" { + sessionKey = tokenData.LoginMethod + ":" + tokenData.UserID + } + if err := p.MemoryStoreProvider.DeleteUserSession(sessionKey, tokenData.Nonce); err != nil { + log.Debug().Err(err).Msg("Failed to delete user session") + return nil, nil, err + } + + for _, c := range cookie.BuildDeleteSessionCookies(meta.HostURL, p.Config.AppCookieSecure, cookie.ParseSameSite(p.Config.AppCookieSameSite)) { + side.AddCookie(c) + } + + metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) + metrics.ActiveSessions.Dec() + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditLogoutEvent, + ActorID: tokenData.UserID, + ActorType: constants.AuditActorTypeUser, + ResourceType: constants.AuditResourceTypeSession, + ResourceID: tokenData.UserID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Logged out successfully"}, side, nil +} diff --git a/internal/service/permission_check.go b/internal/service/permission_check.go new file mode 100644 index 00000000..886614c7 --- /dev/null +++ b/internal/service/permission_check.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "errors" + + "github.com/rs/zerolog" + + "github.com/authorizerdev/authorizer/internal/authorization" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" +) + +// enforceRequiredPermissions evaluates each required permission against the +// authorization provider with AND semantics — every entry must be allowed, +// otherwise the caller is treated as unauthorized. Direct port of the +// graphqlProvider helper of the same name; the metrics + invariants +// (one terminal return per call, exactly one metric emission) are preserved. +// +// endpoint identifies the operation that called this helper and becomes +// the `endpoint` label on authorizer_required_permissions_checks_total; +// it must be one of metrics.RequiredPermissionsEndpoint* to avoid +// cardinality explosion. +func (p *provider) enforceRequiredPermissions( + ctx context.Context, + log zerolog.Logger, + endpoint string, + userID string, + roles []string, + required []*model.PermissionInput, +) error { + if len(required) == 0 { + metrics.RecordRequiredPermissionsCheck(endpoint, metrics.RequiredPermissionsOutcomeNotRequested) + return nil + } + principal := &authorization.Principal{ + ID: userID, + Type: constants.PrincipalTypeUser, + Roles: roles, + } + for _, pi := range required { + if pi == nil { + continue + } + res, err := p.AuthorizationProvider.CheckPermission(ctx, principal, pi.Resource, pi.Scope) + if err != nil { + log.Debug().Err(err).Str("resource", pi.Resource).Str("scope", pi.Scope).Msg("required permission check errored") + metrics.RecordRequiredPermissionsCheck(endpoint, metrics.RequiredPermissionsOutcomeError) + return errors.New("unauthorized") + } + if res == nil || !res.Allowed { + log.Debug().Str("resource", pi.Resource).Str("scope", pi.Scope).Msg("required permission denied") + metrics.RecordRequiredPermissionsCheck(endpoint, metrics.RequiredPermissionsOutcomeDenied) + return errors.New("unauthorized") + } + } + metrics.RecordRequiredPermissionsCheck(endpoint, metrics.RequiredPermissionsOutcomeGranted) + return nil +} diff --git a/internal/service/permissions.go b/internal/service/permissions.go new file mode 100644 index 00000000..758f5e64 --- /dev/null +++ b/internal/service/permissions.go @@ -0,0 +1,61 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/authorization" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// Permissions returns every (resource, scope) pair the authenticated user +// is allowed to act on, derived from their roles and the policy engine. +// Transport-agnostic port of graphqlProvider.Permissions. +// +// Permissions: authenticated user. +func (p *provider) Permissions(ctx context.Context, meta RequestMetadata) ([]*model.Permission, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "Permissions").Logger() + + gc := &gin.Context{Request: meta.Request} + tokenData, err := p.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user from token") + return nil, nil, fmt.Errorf("unauthorized") + } + + user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user by ID") + return nil, nil, err + } + + var roles []string + if user.Roles != "" { + roles = strings.Split(user.Roles, ",") + } + + principal := &authorization.Principal{ + ID: user.ID, + Type: constants.PrincipalTypeUser, + Roles: roles, + } + + resourceScopes, err := p.AuthorizationProvider.GetPrincipalPermissions(ctx, principal) + if err != nil { + log.Debug().Err(err).Msg("Failed to get principal permissions") + return nil, nil, err + } + + res := make([]*model.Permission, len(resourceScopes)) + for i, rs := range resourceScopes { + res[i] = &model.Permission{ + Resource: rs.Resource, + Scope: rs.Scope, + } + } + return res, nil, nil +} diff --git a/internal/service/profile.go b/internal/service/profile.go new file mode 100644 index 00000000..244d5e36 --- /dev/null +++ b/internal/service/profile.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + "fmt" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// Profile returns the authenticated user. Requires a valid session cookie or +// access-token bearer. Transport-agnostic port of graphqlProvider.Profile. +// +// Permissions: authenticated user. +func (p *provider) Profile(ctx context.Context, meta RequestMetadata) (*model.User, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "Profile").Logger() + + // TokenProvider.GetUserIDFromSessionOrAccessToken takes *gin.Context but + // only reads Request headers (Authorization) and cookies. Synthesize a + // minimal gin context wrapping the inbound *http.Request — same shim + // pattern as the original SignUp migration. + // TODO(grpc): refactor TokenProvider to take *http.Request directly. + gc := &gin.Context{Request: meta.Request} + tokenData, err := p.TokenProvider.GetUserIDFromSessionOrAccessToken(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, fmt.Errorf("unauthorized") + } + user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + if err != nil { + log.Debug().Err(err).Str("user_id", tokenData.UserID).Msg("Failed to get user by id") + return nil, nil, err + } + return user.AsAPIUser(), nil, nil +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 736325ba..50303f50 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -6,6 +6,7 @@ import ( "github.com/rs/zerolog" "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/authorization" "github.com/authorizerdev/authorizer/internal/config" "github.com/authorizerdev/authorizer/internal/email" "github.com/authorizerdev/authorizer/internal/events" @@ -21,13 +22,14 @@ import ( type Dependencies struct { Log *zerolog.Logger - AuditProvider audit.Provider - EmailProvider email.Provider - EventsProvider events.Provider - MemoryStoreProvider memory_store.Provider - SMSProvider sms.Provider - StorageProvider storage.Provider - TokenProvider token.Provider + AuditProvider audit.Provider + AuthorizationProvider authorization.Provider + EmailProvider email.Provider + EventsProvider events.Provider + MemoryStoreProvider memory_store.Provider + SMSProvider sms.Provider + StorageProvider storage.Provider + TokenProvider token.Provider } // Provider is the transport-agnostic API for Authorizer public operations. @@ -40,13 +42,36 @@ type Dependencies struct { // graphqlProvider methods until they're moved here. type Provider interface { // SignUp registers a new user. Public — no authentication required. - // Permissions: none. SignUp(ctx context.Context, meta RequestMetadata, params *model.SignUpRequest) (*model.AuthResponse, *ResponseSideEffects, error) // Meta returns server discovery information (feature flags + provider // availability). Public — no authentication required. - // Permissions: none. Meta(ctx context.Context, meta RequestMetadata) (*model.Meta, *ResponseSideEffects, error) + + // Profile returns the authenticated user. Requires session/bearer auth. + Profile(ctx context.Context, meta RequestMetadata) (*model.User, *ResponseSideEffects, error) + + // Permissions returns (resource, scope) pairs the caller is allowed to + // act on. Requires session/bearer auth. + Permissions(ctx context.Context, meta RequestMetadata) ([]*model.Permission, *ResponseSideEffects, error) + + // Logout ends the caller's current session. Browser callers get + // expired Set-Cookie headers via side-effects. Requires auth. + Logout(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + + // Revoke invalidates a refresh token. Typed mirror of RFC 7009. + Revoke(ctx context.Context, meta RequestMetadata, params *model.OAuthRevokeRequest) (*model.Response, *ResponseSideEffects, error) + + // ValidateJwtToken validates a JWT (access/id/refresh) without rotation. + ValidateJwtToken(ctx context.Context, meta RequestMetadata, params *model.ValidateJWTTokenRequest) (*model.ValidateJWTTokenResponse, *ResponseSideEffects, error) + + // ValidateSession validates a cookie session without rotation. + ValidateSession(ctx context.Context, meta RequestMetadata, params *model.ValidateSessionRequest) (*model.ValidateSessionResponse, *ResponseSideEffects, error) + + // Session returns the AuthResponse bound to the caller's cookie/bearer + // AND rotates the session token. Browser callers get a fresh + // Set-Cookie via side-effects. + Session(ctx context.Context, meta RequestMetadata, params *model.SessionQueryRequest) (*model.AuthResponse, *ResponseSideEffects, error) } // New constructs a new service provider. diff --git a/internal/service/revoke.go b/internal/service/revoke.go new file mode 100644 index 00000000..7acc1714 --- /dev/null +++ b/internal/service/revoke.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "errors" + "strings" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// Revoke invalidates a refresh token. Typed mirror of RFC 7009. +// Transport-agnostic port of graphqlProvider.Revoke. +func (p *provider) Revoke(ctx context.Context, meta RequestMetadata, params *model.OAuthRevokeRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "Revoke").Logger() + tok := strings.TrimSpace(params.RefreshToken) + if tok == "" { + log.Error().Msg("Refresh token is empty") + return nil, nil, errors.New("missing refresh token") + } + claims, err := p.TokenProvider.ParseJWTToken(tok) + if err != nil { + log.Debug().Err(err).Msg("failed to parse jwt") + return nil, nil, err + } + + userID, ok := claims["sub"].(string) + if !ok || userID == "" { + log.Debug().Msg("Invalid subject in token") + return nil, nil, errors.New("invalid token") + } + loginMethod := claims["login_method"] + sessionKey := userID + if lm, ok := loginMethod.(string); ok && lm != "" { + sessionKey = lm + ":" + userID + } + + nonce, ok := claims["nonce"].(string) + if !ok || nonce == "" { + log.Debug().Msg("Invalid nonce in token") + return nil, nil, errors.New("invalid token") + } + + existing, err := p.MemoryStoreProvider.GetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+nonce) + if err != nil { + log.Debug().Err(err).Msg("Failed to get refresh token") + return nil, nil, err + } + if existing == "" { + log.Debug().Msg("Token not found") + return nil, nil, errors.New("token not found") + } + if existing != tok { + log.Debug().Msg("Token does not match") + return nil, nil, errors.New("token does not match") + } + + if err := p.MemoryStoreProvider.DeleteUserSession(sessionKey, nonce); err != nil { + log.Debug().Err(err).Msg("failed to delete user session") + return nil, nil, err + } + return &model.Response{Message: "Token revoked"}, nil, nil +} diff --git a/internal/service/session.go b/internal/service/session.go new file mode 100644 index 00000000..7755674b --- /dev/null +++ b/internal/service/session.go @@ -0,0 +1,162 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// Session returns the AuthResponse bound to the caller's cookie/bearer and +// rotates the session token in the process. Transport-agnostic port of +// graphqlProvider.Session. +// +// Security note: SessionResponse carries access_token, refresh_token, +// id_token, and authenticator-enrolment fields. Per security audit C1, the +// proto annotation on Session is `mcp_tool.exposed = false` — so this +// response shape never lands in an MCP/LLM transcript. +func (p *provider) Session(ctx context.Context, meta RequestMetadata, params *model.SessionQueryRequest) (*model.AuthResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "Session").Logger() + side := &ResponseSideEffects{} + + gc := &gin.Context{Request: meta.Request} + sessionToken, err := cookie.GetSession(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get session token") + return nil, nil, errors.New("unauthorized") + } + + claims, err := p.TokenProvider.ValidateBrowserSession(gc, sessionToken) + if err != nil { + log.Debug().Err(err).Msg("Failed to validate session token") + return nil, nil, errors.New("unauthorized") + } + userID := claims.Subject + log = log.With().Str("user_id", userID).Logger() + user, err := p.StorageProvider.GetUserByID(ctx, userID) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user") + return nil, nil, err + } + + claimRoles := append([]string{}, claims.Roles...) + if params != nil && len(params.Roles) > 0 { + for _, v := range params.Roles { + if !utils.StringSliceContains(claimRoles, v) { + log.Debug().Msg("User does not have required role") + return nil, nil, fmt.Errorf(`unauthorized`) + } + } + } + + if params != nil { + if err := p.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointSession, user.ID, claimRoles, params.RequiredPermissions); err != nil { + return nil, nil, err + } + } + + scope := []string{"openid", "email", "profile"} + if params != nil && len(params.Scope) > 0 { + scope = params.Scope + } + + // OIDC authorize flow: if state is provided, consume the authorize state + // and prepare code/challenge data so the authorization code can be stored + // after token creation. + code := "" + codeChallenge := "" + oidcNonce := "" + authorizeRedirectURI := "" + if params != nil && params.State != nil { + authorizeState, _ := p.MemoryStoreProvider.GetState(refs.StringValue(params.State)) + if authorizeState != "" { + parts := strings.Split(authorizeState, "@@") + if len(parts) > 1 { + code = parts[0] + codeChallenge = parts[1] + if len(parts) > 2 { + oidcNonce = parts[2] + } + if len(parts) > 3 { + authorizeRedirectURI = parts[3] + } + } + p.MemoryStoreProvider.RemoveState(refs.StringValue(params.State)) + } + } + + nonce := uuid.New().String() + hostname := meta.HostURL + authToken, err := p.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{ + User: user, + Nonce: nonce, + OIDCNonce: oidcNonce, + Code: code, + Roles: claimRoles, + Scope: scope, + LoginMethod: claims.LoginMethod, + HostName: hostname, + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to CreateAuthToken") + return nil, nil, err + } + + if code != "" { + if err := p.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+oidcNonce+"@@"+authorizeRedirectURI); err != nil { + log.Debug().Err(err).Msg("Failed to set code state") + return nil, nil, err + } + } + + sessionKey := userID + if claims.LoginMethod != "" { + sessionKey = claims.LoginMethod + ":" + userID + } + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + + res := &model.AuthResponse{ + Message: "Session token refreshed", + AccessToken: &authToken.AccessToken.Token, + ExpiresIn: &expiresIn, + IDToken: &authToken.IDToken.Token, + User: user.AsAPIUser(), + } + + // Establish the new session first, then revoke the old one. Doing both + // synchronously closes the window where a stolen pre-rotation token + // remains valid alongside the rotated one; doing "new then old" avoids + // any moment where the user has no valid session token. DeleteUserSession + // is in-memory or a single Redis DEL — failure is non-fatal. + for _, c := range cookie.BuildSessionCookies(meta.HostURL, authToken.FingerPrintHash, p.Config.AppCookieSecure, cookie.ParseSameSite(p.Config.AppCookieSameSite)) { + side.AddCookie(c) + } + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt) + + if authToken.RefreshToken != nil { + res.RefreshToken = &authToken.RefreshToken.Token + p.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) + } + + if err := p.MemoryStoreProvider.DeleteUserSession(sessionKey, claims.Nonce); err != nil { + log.Warn().Err(err).Str("session_key", sessionKey).Msg("failed to delete old session during rollover") + } + return res, side, nil +} diff --git a/internal/service/validate_jwt_token.go b/internal/service/validate_jwt_token.go new file mode 100644 index 00000000..c55a7af1 --- /dev/null +++ b/internal/service/validate_jwt_token.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v4" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// ValidateJwtToken validates a JWT without rotating it. Used at the API +// level (backend) — accepts access_token, id_token, or refresh_token. +// Transport-agnostic port of graphqlProvider.ValidateJWTToken. +func (p *provider) ValidateJwtToken(ctx context.Context, meta RequestMetadata, params *model.ValidateJWTTokenRequest) (*model.ValidateJWTTokenResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "ValidateJwtToken").Logger() + + tokenType := params.TokenType + if tokenType != constants.TokenTypeAccessToken && tokenType != constants.TokenTypeRefreshToken && tokenType != constants.TokenTypeIdentityToken { + log.Debug().Str("token_type", tokenType).Msg("Invalid token type") + return nil, nil, errors.New("invalid token type") + } + + var claimRoles []string + var claims jwt.MapClaims + userID := "" + nonce := "" + + claims, err := p.TokenProvider.ParseJWTToken(params.Token) + if err != nil { + log.Debug().Err(err).Msg("Failed to parse jwt token") + return nil, nil, err + } + sub, ok := claims["sub"].(string) + if !ok || sub == "" { + log.Debug().Msg("Invalid subject in token") + return nil, nil, errors.New("invalid token") + } + userID = sub + + if tokenType == constants.TokenTypeAccessToken || tokenType == constants.TokenTypeRefreshToken { + nonceVal, ok := claims["nonce"].(string) + if !ok || nonceVal == "" { + log.Debug().Msg("Invalid nonce in token") + return nil, nil, errors.New("invalid token") + } + nonce = nonceVal + loginMethod := claims["login_method"] + sessionKey := userID + if lm, ok := loginMethod.(string); ok && lm != "" { + sessionKey = lm + ":" + userID + } + tok, err := p.MemoryStoreProvider.GetUserSession(sessionKey, tokenType+"_"+nonceVal) + if err != nil || tok == "" { + log.Debug().Err(err).Msg("Failed to get token from session store") + return nil, nil, errors.New("invalid token") + } + } + + hostname := meta.HostURL + if nonce != "" { + if ok, err := p.TokenProvider.ValidateJWTClaims(claims, &token.AuthTokenConfig{ + HostName: hostname, + Nonce: nonce, + User: &schemas.User{ID: userID}, + }); !ok || err != nil { + log.Debug().Err(err).Msg("Failed to validate jwt claims") + return nil, nil, errors.New("invalid claims") + } + } else { + if ok, err := p.TokenProvider.ValidateJWTTokenWithoutNonce(claims, &token.AuthTokenConfig{ + HostName: hostname, + User: &schemas.User{ID: userID}, + }); !ok || err != nil { + log.Debug().Err(err).Msg("Failed to validate jwt claims") + return nil, nil, errors.New("invalid claims") + } + } + + // Read roles from the configured claim key (used for id_token), falling + // back to the hardcoded "roles" claim that CreateAccessToken emits. + claimRolesInterface := claims[p.Config.JWTRoleClaim] + roleSlice := utils.ConvertInterfaceToSlice(claimRolesInterface) + if len(roleSlice) == 0 { + roleSlice = utils.ConvertInterfaceToSlice(claims["roles"]) + } + for _, v := range roleSlice { + roleStr, ok := v.(string) + if !ok || roleStr == "" { + log.Debug().Msg("Invalid role claim value") + return nil, nil, errors.New("invalid claims") + } + claimRoles = append(claimRoles, roleStr) + } + + if len(params.Roles) > 0 { + for _, v := range params.Roles { + if !utils.StringSliceContains(claimRoles, v) { + log.Debug().Str("role", v).Msg("Role not found in claims") + return nil, nil, fmt.Errorf(`unauthorized`) + } + } + } + if err := p.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointValidateJWTToken, userID, claimRoles, params.RequiredPermissions); err != nil { + return nil, nil, err + } + return &model.ValidateJWTTokenResponse{ + IsValid: true, + Claims: claims, + }, nil, nil +} diff --git a/internal/service/validate_session.go b/internal/service/validate_session.go new file mode 100644 index 00000000..0ea18a42 --- /dev/null +++ b/internal/service/validate_session.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// ValidateSession validates a cookie session without rotating it. +// Transport-agnostic port of graphqlProvider.ValidateSession. +// +// Resolution order for the session cookie: explicit params.Cookie first, +// then the request cookies (via cookie.GetSession with a gin shim). Both +// are checked because the GraphQL path historically fell back to the +// cookie when params was empty. +func (p *provider) ValidateSession(ctx context.Context, meta RequestMetadata, params *model.ValidateSessionRequest) (*model.ValidateSessionResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "ValidateSession").Logger() + + // TokenProvider.ValidateBrowserSession + cookie.GetSession both take + // *gin.Context but only read Request fields. Shim it. + gc := &gin.Context{Request: meta.Request} + + sessionToken := "" + if params != nil && params.Cookie != "" { + sessionToken = params.Cookie + } else { + var err error + sessionToken, err = cookie.GetSession(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get session token") + return nil, nil, errors.New("unauthorized") + } + } + if sessionToken == "" { + log.Debug().Msg("Empty session token") + return nil, nil, errors.New("unauthorized") + } + + claims, err := p.TokenProvider.ValidateBrowserSession(gc, sessionToken) + if err != nil { + log.Debug().Err(err).Msg("Failed to validate session") + return nil, nil, errors.New("unauthorized") + } + userID := claims.Subject + log.Debug().Str("userID", userID).Msg("Validated session") + user, err := p.StorageProvider.GetUserByID(ctx, userID) + if err != nil { + log.Debug().Err(err).Msg("failed GetUserByID") + return nil, nil, err + } + + claimRoles := append([]string{}, claims.Roles...) + if params != nil && len(params.Roles) > 0 { + for _, v := range params.Roles { + if !utils.StringSliceContains(claimRoles, v) { + log.Debug().Str("role", v).Msg("Role not found in claims") + return nil, nil, fmt.Errorf(`unauthorized`) + } + } + } + if params != nil { + if err := p.enforceRequiredPermissions(ctx, log, metrics.RequiredPermissionsEndpointValidateSession, user.ID, claimRoles, params.RequiredPermissions); err != nil { + return nil, nil, err + } + } + return &model.ValidateSessionResponse{ + IsValid: true, + User: user.AsAPIUser(), + }, nil, nil +} diff --git a/proto/authorizer/v1/authorizer.proto b/proto/authorizer/v1/authorizer.proto index e6301f62..6e94ea94 100644 --- a/proto/authorizer/v1/authorizer.proto +++ b/proto/authorizer/v1/authorizer.proto @@ -167,12 +167,14 @@ service AuthorizerService { } // Session returns the AuthResponse bound to the caller's cookie/bearer. + // NOT exposed as an MCP tool — SessionResponse carries access_token, + // refresh_token, id_token, authenticator_secret, and recovery codes, + // none of which should land in an LLM transcript. (Security audit C1.) rpc Session(SessionRequest) returns (SessionResponse) { option (google.api.http) = { post: "/v1/session" body: "*" }; - option (authorizer.common.v1.mcp_tool) = {exposed: true}; } rpc ValidateJwtToken(ValidateJwtTokenRequest) returns (ValidateJwtTokenResponse) {