From 5167732f330da502684f9a44eebf9d3b78e55e7a Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:09:15 -0500 Subject: [PATCH 01/12] fix(accounts): central RBAC admission enforces unless an operator opts out, and refusals are counted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `configuredCentralEnforcement` read an unset `RBAC_CENTRAL_ENFORCEMENT` as the classify-only reading, so every deployment that configured nothing ran the central org-tenant floor in shadow: it recorded coverage and denied nothing. An unset value now enforces. `shadow` remains reachable, as an explicit opt-out that the boot warns about by name on every start, and any other value still fails the boot. The reader returns a two-valued mode rather than a bool so "nobody configured this" and "an operator asked for classify-only" are different states rather than the same empty string. The interceptors' own `centralEnforcement` variable is initialized to the enforcing value for the same reason: Go's zero value meaning shadow is what made every test in that package, and every caller reaching an interceptor without passing through startup, exercise the weaker admission while the configuration claimed the stricter one. Turning it on made the host-interface conformance suite refuse 30 cases, all on `host.authority.v1.AuthorityService/Issue`: a shape-authenticated method, whose caller's credential is in the request body rather than in a bearer, so admission has no verified organization to resolve the declared tenant against and refused every workload, installation and child hop with "verified authentication context required" before the adapter could authenticate the credential it was handed. `business.CentralTenantEnforcement` now declines a shape-authenticated method, and `ClassifyCentralCoverage` calls it a gap, so the two halves keep one answer per method. Refusals are counted on a new `saas.accounts.rpc_policy.denied`, labelled by `method` and by the gate that refused — `admission`, `impersonation_restricted`, `api_key_scope`, `central_admission`, `handler` — on both transports. The reason is a closed set of constants, never the error message, which carries the organization and the permission. Only PermissionDenied and Unauthenticated are counted: the interceptor sees every handler error pass through, and counting an Internal would make an authorization metric a general error rate. Co-Authored-By: Claude Opus 5 --- AUTHORITY_CHECKING_PLAN.md | 9 + AUTHZ.md | 24 +++ PLATFORM_REFERENCE.md | 25 ++- module/MEASUREMENT_RUNBOOKS.md | 42 +++++ module/configurations/README.md | 6 +- .../pkg/adapters/connect_auth_interceptor.go | 43 ++--- .../pkg/adapters/grpc_auth_interceptor.go | 154 +++++++++++++++--- .../pkg/adapters/rpc_policy_denied_test.go | 117 +++++++++++++ .../pkg/adapters/rpc_policy_enforce_test.go | 31 +++- .../code/pkg/business/rpc_policy_coverage.go | 29 +++- module/services/accounts/code/work.go | 48 ++++-- .../code/work_central_enforcement_test.go | 69 ++++++++ 12 files changed, 528 insertions(+), 69 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/rpc_policy_denied_test.go create mode 100644 module/services/accounts/code/work_central_enforcement_test.go diff --git a/AUTHORITY_CHECKING_PLAN.md b/AUTHORITY_CHECKING_PLAN.md index 2c1ba8246..403a67877 100644 --- a/AUTHORITY_CHECKING_PLAN.md +++ b/AUTHORITY_CHECKING_PLAN.md @@ -128,6 +128,15 @@ round numbers undercount): admission. #415 notes a shadow-mode patch exists against an older base and needs a rebase — treat it as a starting point, not merge-ready. +> **Since this record was written (3 Nov 2026, #1296).** Workstream A shipped and +> then flipped: the classifier +> (`business.ClassifyCentralCoverage`, metric `saas.accounts.rpc_policy.coverage`) +> and the central floor (`enforceCentralPolicy`) both exist, and enforcement is +> now what an unset `RBAC_CENTRAL_ENFORCEMENT` selects — `shadow` is an explicit, +> boot-warned opt-out. The paragraph above and the "Shape of the fix" below are +> kept as the scoping record they were, not as current state; current state is +> [`AUTHZ.md`](AUTHZ.md) "The central floor beneath the handler gates". + **Shape of the fix (for #415).** A central interceptor that reads the *full* `MethodPolicy` and, per call, classifies the outcome `ok` / `gap` / `broadening` / `unsupported`, emitting an audit signal and **changing no diff --git a/AUTHZ.md b/AUTHZ.md index e8de0de6b..05d4b1560 100644 --- a/AUTHZ.md +++ b/AUTHZ.md @@ -172,6 +172,30 @@ bypass on sensitive ops, IP/key floods. up the wrong row (e.g., `getWebhook(id)` without filtering by orgID). That's L2 + L3. +### The central floor beneath the handler gates + +Before any handler runs, the interceptors apply the same org-member/org-admin +tenant the descriptor declares, against the caller's *verified* organization +(`enforceCentralPolicy`, `business.CentralTenantEnforcement`). It is a redundant +floor: it is never stricter than the `require*` site that still runs after it, +and it resolves only the subset it can decide without reading the request body — +no declared permission, platform role, MFA requirement, team or owned-resource +binding, no global-scope escape (`globalScopeEscapeMethods`) and no +shape-authenticated method (`business.ShapeAuthenticated`, whose caller's +credential is *in* the request body, so admission has no verified organization to +resolve against). Everything else stays with the handler gates above. + +**It enforces by default.** `RBAC_CENTRAL_ENFORCEMENT` unset means enforce; +`shadow` is an explicit opt-out that classifies only, and the boot warns naming +it on every start; any other value refuses to boot. The coverage classification +that drove the rollout (`saas.accounts.rpc_policy.coverage`) keeps running, since +what it measures is the part this floor still cannot reach. + +Refusals are counted on `saas.accounts.rpc_policy.denied`, labelled by `method` +and by the gate that refused (`reason`) — see +[module/MEASUREMENT_RUNBOOKS.md](module/MEASUREMENT_RUNBOOKS.md) under +`authorization-denials`. + ## Layer 2 — Permissions (RBAC, business layer) **Where:** `pkg/business/service.go:CheckPermission` + the diff --git a/PLATFORM_REFERENCE.md b/PLATFORM_REFERENCE.md index d103869c9..2e2ff5706 100644 --- a/PLATFORM_REFERENCE.md +++ b/PLATFORM_REFERENCE.md @@ -86,10 +86,13 @@ from the audited platform, so read the mapping carefully. proto+codegen instead of JSON+symlink. [module/AUTHORIZATION_CATALOG.md](./module/AUTHORIZATION_CATALOG.md), [module/METHOD_POLICY.md](./module/METHOD_POLICY.md). - **Enforce-by-default, ✅:** the audit's headline mistake was `RBAC_MODE` - defaulting to shadow and *failing open*. The starter has **no shadow mode** — - it is deny-by-default, always-enforce. That is the audit's recommendation - already in force; do not add a fail-open shadow toggle without a boot-validated - enum. + defaulting to shadow and *failing open*. The starter is deny-by-default and + enforces by default: exposure, impersonation and API-key-scope admission have + no off switch at all, and the central org-tenant floor enforces unless a + deployment names `RBAC_CENTRAL_ENFORCEMENT=shadow`, which the boot warns about + by name on every start. The enum is boot-validated — an unknown value refuses + to start — so no typo can turn enforcement off. Do not add a switch that + weakens authorization without both of those properties. - **Agent/machine principals, ✅:** `principals` table with kinds `human`/`service`/`agent` (`pkg/business/principals.go`); agents are human-owned (`publisher/name:version`); `SubjectKind_PRINCIPAL` is canonical. @@ -293,8 +296,10 @@ as designed, not proven, until exercised end to end. 1. **One source of truth, generated into every consumer** — proto `MethodPolicy` → `authz-methods.json` → ext_authz + gateway + REST, drift-checked in CI. The starter's analog of "one policy file, two runtimes". -2. **Enforce-by-default, deny-by-default** — no fail-open shadow mode; missing - policy fails generation. This is the audit's #1 mistake pre-avoided. +2. **Enforce-by-default, deny-by-default** — the one classify-only switch + (`RBAC_CENTRAL_ENFORCEMENT`) defaults to enforcing, is boot-validated, and + names itself in a boot warning when an operator opts out; missing policy + fails generation. This is the audit's #1 mistake pre-avoided. 3. **Structural CI gates over review discipline** — authz/audit coverage, RLS coverage, permission no-broadening, header lockstep (§5). 4. **Two-token S2S** with attenuating, audience-bound, hash-chained actor @@ -311,8 +316,12 @@ as designed, not proven, until exercised end to end. Drawn from the audit's "what's bad" list, filtered to what applies here: -1. **Don't add a fail-open shadow mode.** If a migration ever needs one, - boot-validate the enum and refuse to start on unknown values. +1. **Don't add a fail-open shadow mode.** If a migration ever needs one, give + it the three properties `RBAC_CENTRAL_ENFORCEMENT` has: the strict reading is + the default an unset value selects, the enum is boot-validated so an unknown + value refuses to start, and the weak reading logs a warning naming itself at + every boot. An operator switch whose unset value is the permissive reading is + the same mistake under a different name. 2. **Make any background-automation kill switch per-tenant**, never deployment-global (§1.5). 3. **Keep read-only/support authority server-enforced**, never cosmetic — the diff --git a/module/MEASUREMENT_RUNBOOKS.md b/module/MEASUREMENT_RUNBOOKS.md index 36c1ed2af..d2d20f204 100644 --- a/module/MEASUREMENT_RUNBOOKS.md +++ b/module/MEASUREMENT_RUNBOOKS.md @@ -71,6 +71,48 @@ provider responses into an incident channel. 5. Verify source events, aggregate, customer history, entitlement limit, and provider quantity agree after the late-arrival window. +## `authorization-denials` + +The two authorization instruments accounts exports, both from +`pkg/adapters/grpc_auth_interceptor.go` and both on the meter +`github.com/codefly-dev/module-saas-starter/accounts`. They are named here so an +operator — or the cell's alerting, which owns its own rules — can watch them +without reading the service's source. + +| Metric | Unit | Labels | Counts | +| --- | --- | --- | --- | +| `saas.accounts.rpc_policy.denied` | calls | `method`, `reason` | every authorization refusal, on both the gRPC and the Connect transport | +| `saas.accounts.rpc_policy.coverage` | calls | `method`, `coverage` | every **admitted** call, classified by how much of its declared floor central admission can enforce (`ok`, `gap`, `unsupported`) | + +`reason` is a closed set, never an error message — a message carries the +organization and the permission, which would make the label unbounded and put +tenant identifiers in a metrics backend: + +| `reason` | The gate that refused | +| --- | --- | +| `admission` | exposure, transport, credential: an unclassified method, a malformed or ambiguous forwarded identity, a missing internal or module credential, an invalid or expired bearer | +| `impersonation_restricted` | the method is withheld from a session acting as another user | +| `api_key_scope` | an API key reached a method that accepts none, or whose declared scopes it does not cover | +| `central_admission` | the declared org-member/org-admin tenant floor, resolved against the caller's verified organization | +| `handler` | the handler's own `require*` site, after admission admitted the call | + +1. A refusal is ordinary traffic — a person asking for what they may not have is + a denial, not an incident. What is worth alerting on is a *change*: a rate of + `central_admission` or `api_key_scope` refusals that was near zero and is not, + on one `method`, which is a caller systematically asking for authority it does + not hold or a declaration that stopped matching its handler. +2. Correlate with the request log, which carries the organization and the actor + this metric deliberately does not. Never widen a policy to clear an alert. +3. `coverage` is the other half: `gap` and `unsupported` say which methods the + central floor still leaves to their handler gates. A method moving from `ok` + to `gap` means its declaration changed — read it against + [../AUTHZ.md](../AUTHZ.md) "The central floor beneath the handler gates" + before concluding anything about traffic. +4. `saas.accounts.rpc_policy.denied` going to **zero** on a method that normally + refuses is also a signal: with `RBAC_CENTRAL_ENFORCEMENT=shadow` the central + floor denies nothing, and the boot warning naming that key is the only other + place that state is visible. + ## `retention` 1. Read the accounts `retention` log line `scheduled run failed`. It names each diff --git a/module/configurations/README.md b/module/configurations/README.md index fd7da8844..c497751ac 100644 --- a/module/configurations/README.md +++ b/module/configurations/README.md @@ -91,13 +91,15 @@ this key, and refuses rather than presenting an origin accounts will refuse. ## security Session, MFA and WebAuthn policy, plus three operator switches whose empty value -is the shipped behaviour: +is the shipped behaviour. Every one of the three is empty-means-strict: the +shipped behaviour is the stricter reading, and a deployment reaches the weaker +one only by naming it. | Key | Empty means | Set to | | --- | --- | --- | | `TRUSTED_PROXY_CIDRS` | no forwarding header is trusted, so every per-IP budget buckets on the peer — behind an ingress and a frontend hop, one bucket for the whole cell | the cell's ingress and frontend ranges, comma-separated exact IPs or CIDRs. A malformed entry fails the boot rather than trusting a spoofable header. Distinct from the identically named key in the `gateway` group, which is auth-gateway's own edge and which a deployed gateway refuses to start without | | `ACCOUNTS_REVOCATION_FAIL_OPEN` | a token whose revocation status cannot be read is denied — fail **closed** | `true`, only when fronting accounts directly with no auth-gateway, to keep the direct verify path serving through a revocation-store outage at the cost of a revoked token's remaining TTL | -| `RBAC_CENTRAL_ENFORCEMENT` | `shadow`: request-time central RBAC admission classifies only | `enforce` to hard-deny. Any other value fails the boot, so a typo cannot leave enforcement off | +| `RBAC_CENTRAL_ENFORCEMENT` | `enforce`: request-time central RBAC admission hard-denies a call whose declared org-tenant floor the caller does not satisfy | `shadow` to classify only and deny nothing — an explicit opt-out the boot warns about by name, every boot. Any other value fails the boot, so a typo cannot leave enforcement off | ## internal-transport diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index 936c0d702..44332ddd0 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -181,23 +181,25 @@ func connectAuthInterceptor(getMinter func() auth.JWTMinter) connect.Interceptor func (i *connectPolicyInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - ctx, err := i.authorize(ctx, req.Spec().Procedure, req.Header()) + procedure := req.Spec().Procedure + ctx, err := i.authorize(ctx, procedure, req.Header()) if err != nil { - return nil, err + return nil, recordPolicyDenial(ctx, procedure, denialReasonAdmission, err) } - if err := enforceImpersonationPolicy(ctx, req.Spec().Procedure); err != nil { - return nil, translateGRPCError(err) + if err := enforceImpersonationPolicy(ctx, procedure); err != nil { + return nil, translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonImpersonation, err)) } - if err := enforceAPIKeyScopePolicy(ctx, req.Spec().Procedure); err != nil { - return nil, translateGRPCError(err) + if err := enforceAPIKeyScopePolicy(ctx, procedure); err != nil { + return nil, translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonAPIKeyScope, err)) } - if err := enforceCentralPolicy(ctx, req.Spec().Procedure); err != nil { - return nil, translateGRPCError(err) + if err := enforceCentralPolicy(ctx, procedure); err != nil { + return nil, translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonCentral, err)) } - shadowPolicyCoverage(ctx, req.Spec().Procedure) + shadowPolicyCoverage(ctx, procedure) // One committed module-envelope revision read per unary request, // however many module authority decisions the handler makes. - return next(business.WithModuleEnvelopeRevisionMemo(ctx), req) + response, err := next(business.WithModuleEnvelopeRevisionMemo(ctx), req) + return response, recordPolicyDenial(ctx, procedure, denialReasonHandler, err) } } @@ -207,21 +209,22 @@ func (i *connectPolicyInterceptor) WrapStreamingClient(next connect.StreamingCli func (i *connectPolicyInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - ctx, err := i.authorize(ctx, conn.Spec().Procedure, conn.RequestHeader()) + procedure := conn.Spec().Procedure + ctx, err := i.authorize(ctx, procedure, conn.RequestHeader()) if err != nil { - return err + return recordPolicyDenial(ctx, procedure, denialReasonAdmission, err) } - if err := enforceImpersonationPolicy(ctx, conn.Spec().Procedure); err != nil { - return translateGRPCError(err) + if err := enforceImpersonationPolicy(ctx, procedure); err != nil { + return translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonImpersonation, err)) } - if err := enforceAPIKeyScopePolicy(ctx, conn.Spec().Procedure); err != nil { - return translateGRPCError(err) + if err := enforceAPIKeyScopePolicy(ctx, procedure); err != nil { + return translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonAPIKeyScope, err)) } - if err := enforceCentralPolicy(ctx, conn.Spec().Procedure); err != nil { - return translateGRPCError(err) + if err := enforceCentralPolicy(ctx, procedure); err != nil { + return translateGRPCError(recordPolicyDenial(ctx, procedure, denialReasonCentral, err)) } - shadowPolicyCoverage(ctx, conn.Spec().Procedure) - return next(ctx, conn) + shadowPolicyCoverage(ctx, procedure) + return recordPolicyDenial(ctx, procedure, denialReasonHandler, next(ctx, conn)) } } diff --git a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go index 0cc22971f..28739ce57 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "connectrpc.com/connect" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -67,19 +68,20 @@ func grpcAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExposure) return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { ctx, err := authorizer.authorize(ctx, info.FullMethod) if err != nil { - return nil, err + return nil, recordPolicyDenial(ctx, info.FullMethod, denialReasonAdmission, err) } if err := enforceImpersonationPolicy(ctx, info.FullMethod); err != nil { - return nil, err + return nil, recordPolicyDenial(ctx, info.FullMethod, denialReasonImpersonation, err) } if err := enforceAPIKeyScopePolicy(ctx, info.FullMethod); err != nil { - return nil, err + return nil, recordPolicyDenial(ctx, info.FullMethod, denialReasonAPIKeyScope, err) } if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { - return nil, err + return nil, recordPolicyDenial(ctx, info.FullMethod, denialReasonCentral, err) } shadowPolicyCoverage(ctx, info.FullMethod) - return handler(business.WithModuleEnvelopeRevisionMemo(ctx), req) + response, err := handler(business.WithModuleEnvelopeRevisionMemo(ctx), req) + return response, recordPolicyDenial(ctx, info.FullMethod, denialReasonHandler, err) } } @@ -88,27 +90,121 @@ func grpcStreamAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExpo return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { ctx, err := authorizer.authorize(stream.Context(), info.FullMethod) if err != nil { - return err + return recordPolicyDenial(ctx, info.FullMethod, denialReasonAdmission, err) } if err := enforceImpersonationPolicy(ctx, info.FullMethod); err != nil { - return err + return recordPolicyDenial(ctx, info.FullMethod, denialReasonImpersonation, err) } if err := enforceAPIKeyScopePolicy(ctx, info.FullMethod); err != nil { - return err + return recordPolicyDenial(ctx, info.FullMethod, denialReasonAPIKeyScope, err) } if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { - return err + return recordPolicyDenial(ctx, info.FullMethod, denialReasonCentral, err) } shadowPolicyCoverage(ctx, info.FullMethod) - return handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx}) + return recordPolicyDenial(ctx, info.FullMethod, denialReasonHandler, + handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx})) } } var ( coverageCounterOnce sync.Once coverageCounter metric.Int64Counter + denialCounterOnce sync.Once + denialCounter metric.Int64Counter ) +// The reasons rpcPolicyDeniedCounter is labelled with. A closed set of +// constants, never an error message: the message carries the organization and +// the permission, which would make the label set unbounded and put tenant +// identifiers in a metrics backend. What a denial was is the reason; who it was +// about is the audit log's and the request log's. +const ( + // denialReasonAdmission: authorize refused before any policy gate — an + // unclassified method, a malformed or ambiguous forwarded identity, a + // missing internal/module credential, an invalid or expired bearer. + denialReasonAdmission = "admission" + // denialReasonImpersonation: the method is withheld from a session acting + // as another user. + denialReasonImpersonation = "impersonation_restricted" + // denialReasonAPIKeyScope: an API key was presented to a method that + // accepts none, or whose declared scopes the key does not cover. + denialReasonAPIKeyScope = "api_key_scope" + // denialReasonCentral: central admission denied the method's declared + // org-tenant floor. This is the count that says what flipping + // RBAC_CENTRAL_ENFORCEMENT to enforce actually refuses. + denialReasonCentral = "central_admission" + // denialReasonHandler: the handler's own require* site refused, after + // admission admitted the call. + denialReasonHandler = "handler" +) + +// isPolicyRefusal reports whether err is a refusal, in either representation +// the same gate produces: a gRPC status on the gRPC path, a connect.Error on +// the Connect path, where some gates are translated before this sees them and +// some (the admission gate) refuse as connect errors in the first place. +// Checking only one of the two would count a refusal on one transport and +// silently drop the identical refusal on the other, which is the failure a +// metric meant for alerting can least afford. +func isPolicyRefusal(err error) bool { + switch status.Code(err) { + case codes.PermissionDenied, codes.Unauthenticated: + return true + } + var connectErr *connect.Error + if errors.As(err, &connectErr) { + switch connectErr.Code() { + case connect.CodePermissionDenied, connect.CodeUnauthenticated: + return true + } + } + return false +} + +// rpcPolicyDeniedCounter lazily binds the denial counter, on first use and for +// the same reason as rpcPolicyCoverageCounter: startup installs the real meter +// provider after this package is linked, so an instrument created at init would +// be a permanent no-op. A creation error leaves it nil and recording is skipped +// rather than failing a request — a metric must never be the reason a refusal +// does not reach its caller. +func rpcPolicyDeniedCounter() metric.Int64Counter { + denialCounterOnce.Do(func() { + counter, err := otel.Meter("github.com/codefly-dev/module-saas-starter/accounts").Int64Counter( + "saas.accounts.rpc_policy.denied", + metric.WithDescription("Authorization refusals per RPC, by the gate that refused."), + ) + if err != nil { + return + } + denialCounter = counter + }) + return denialCounter +} + +// recordPolicyDenial counts err as an authorization refusal attributed to +// reason, and returns err unchanged so a call site stays a one-line wrap of the +// error it already returned. A nil error, and an error that is not a refusal, +// are not counted: the interceptor sees every handler error pass through, and +// counting an Internal or an InvalidArgument here would make the metric a +// general error rate under an authorization name. +// +// Unauthenticated is counted alongside PermissionDenied because the central +// floor returns it for a call carrying no verified database scope, which is a +// refusal of exactly the kind this metric exists to make alertable; leaving it +// out would silently drop that branch. +func recordPolicyDenial(ctx context.Context, fullMethod, reason string, err error) error { + if err == nil || !isPolicyRefusal(err) { + return err + } + if counter := rpcPolicyDeniedCounter(); counter != nil { + counter.Add(ctx, 1, metric.WithAttributes( + attribute.String("method", fullMethod), + attribute.String("reason", reason), + )) + } + return err +} + // rpcPolicyCoverageCounter lazily binds the shadow-coverage counter. It is // created on first use — after startup has installed the real meter provider — // so the instrument is not a permanent no-op. A creation error leaves the @@ -118,7 +214,7 @@ func rpcPolicyCoverageCounter() metric.Int64Counter { coverageCounterOnce.Do(func() { counter, err := otel.Meter("github.com/codefly-dev/module-saas-starter/accounts").Int64Counter( "saas.accounts.rpc_policy.coverage", - metric.WithDescription("Central authorization coverage classification per admitted RPC call (shadow mode)."), + metric.WithDescription("Central authorization coverage classification per admitted RPC call."), ) if err != nil { return @@ -130,11 +226,13 @@ func rpcPolicyCoverageCounter() metric.Int64Counter { // shadowPolicyCoverage classifies how completely the central interceptor could // enforce the method's declared policy floor and records the outcome on a -// counter for every admitted call. It changes no admission decision: this is -// the measurement half of the shadow-mode rollout. Every outcome — including -// ok — is recorded so that the absence of gap/unsupported over real traffic is -// provably "no such traffic" rather than a silent instrument, which is the -// precondition for turning enforcement on. +// counter for every admitted call. It changes no admission decision: it is the +// measurement half, and it keeps running now that enforcement is the default, +// because what it measures is the part central admission still cannot reach. +// Every outcome — including ok — is recorded, so a deployment reporting no +// gap/unsupported is provably serving no such traffic rather than running a +// silent instrument. Refusals are counted separately, by +// rpcPolicyDeniedCounter. // // Classification is static: it reads only the declared policy and never touches // the store. An owned-resource method whose org the interceptor can resolve @@ -159,16 +257,24 @@ func shadowPolicyCoverage(ctx context.Context, fullMethod string) { )) } -// centralEnforcement selects request-time RBAC admission. When false (the -// default, shadow mode) the interceptors record coverage but change no admission -// decision. When true they additionally deny a call whose declared org-tenant -// floor the caller does not satisfy. Set once at startup before the servers -// register their interceptors. -var centralEnforcement bool +// centralEnforcement selects request-time RBAC admission. When true — the +// default — the interceptors deny a call whose declared org-tenant floor the +// caller does not satisfy, as well as recording coverage. When false (shadow +// mode, which a deployment reaches only by naming it in +// RBAC_CENTRAL_ENFORCEMENT) they record coverage and change no admission +// decision. Set once at startup before the servers register their interceptors. +// +// It is initialized to the enforcing value rather than to Go's zero value so +// that the package default and the configured default are the same reading. A +// zero value meaning shadow is what made every test in this package — and every +// caller that reaches an interceptor without going through startup — exercise +// the weaker of the two admissions while the configuration claimed the stricter. +var centralEnforcement = true // SetCentralEnforcementMode turns request-time central RBAC enforcement on or -// off. Shadow (false) is the fallback; enforce (true) denies at admission. Call -// before the gRPC and Connect servers register their interceptors. +// off. Enforce (true) is the default and denies at admission; shadow (false) is +// the explicit opt-out. Call before the gRPC and Connect servers register their +// interceptors. func SetCentralEnforcementMode(enforce bool) { centralEnforcement = enforce } // enforceCentralPolicy denies a call whose declared org-member/admin tenant the diff --git a/module/services/accounts/code/pkg/adapters/rpc_policy_denied_test.go b/module/services/accounts/code/pkg/adapters/rpc_policy_denied_test.go new file mode 100644 index 000000000..d1c781efd --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/rpc_policy_denied_test.go @@ -0,0 +1,117 @@ +package adapters + +import ( + "context" + "errors" + "sync" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + metricnoop "go.opentelemetry.io/otel/metric/noop" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/pkg/auth" +) + +// deniedReader installs a collectable meter provider and rebinds the denial +// counter against it. The rebind is the point: rpcPolicyDeniedCounter binds +// once, lazily, and any earlier test in this package that ran an interceptor +// would otherwise have bound it to the no-op provider, leaving this case +// collecting an empty reader and passing for the wrong reason. +func deniedReader(t *testing.T) *sdkmetric.ManualReader { + t.Helper() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + otel.SetMeterProvider(provider) + denialCounterOnce = sync.Once{} + denialCounter = nil + t.Cleanup(func() { + otel.SetMeterProvider(metricnoop.NewMeterProvider()) + denialCounterOnce = sync.Once{} + denialCounter = nil + }) + return reader +} + +// deniedPoints reads the denial counter, keyed "method reason". +func deniedPoints(t *testing.T, reader *sdkmetric.ManualReader) map[string]int64 { + t.Helper() + var collected metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &collected)) + points := map[string]int64{} + for _, scope := range collected.ScopeMetrics { + for _, m := range scope.Metrics { + if m.Name != "saas.accounts.rpc_policy.denied" { + continue + } + sum, ok := m.Data.(metricdata.Sum[int64]) + require.True(t, ok, "the denial metric is an int64 sum") + for _, point := range sum.DataPoints { + method, hasMethod := point.Attributes.Value("method") + reason, hasReason := point.Attributes.Value("reason") + require.True(t, hasMethod, "a data point carries a method attribute") + require.True(t, hasReason, "a data point carries a reason attribute") + points[method.AsString()+" "+reason.AsString()] += point.Value + } + } + } + return points +} + +const deniedMethod = "/saas.accounts.v1.APIKeyService/CreateAPIKey" + +// A refusal is counted under the gate that produced it, labelled by method, in +// both error representations the two transports produce for the same gate. +func TestPolicyRefusalsAreCountedByMethodAndReason(t *testing.T) { + reader := deniedReader(t) + ctx := context.Background() + + grpcRefusal := status.Error(codes.PermissionDenied, "requires organization administrator or owner role") + connectRefusal := connect.NewError(connect.CodePermissionDenied, errors.New("this operation does not accept API keys")) + + require.Equal(t, grpcRefusal, recordPolicyDenial(ctx, deniedMethod, denialReasonCentral, grpcRefusal)) + require.Equal(t, connectRefusal, recordPolicyDenial(ctx, deniedMethod, denialReasonAPIKeyScope, connectRefusal)) + require.Equal(t, grpcRefusal, recordPolicyDenial(ctx, deniedMethod, denialReasonHandler, grpcRefusal)) + + points := deniedPoints(t, reader) + require.Equal(t, int64(1), points[deniedMethod+" "+denialReasonCentral]) + require.Equal(t, int64(1), points[deniedMethod+" "+denialReasonAPIKeyScope]) + require.Equal(t, int64(1), points[deniedMethod+" "+denialReasonHandler]) +} + +// The central floor's no-verified-scope branch answers Unauthenticated, not +// PermissionDenied. It is a refusal of exactly the kind this metric exists to +// make alertable, so it is counted; a test that only asserted PermissionDenied +// would let that whole branch go dark. +func TestUnauthenticatedCentralRefusalIsCounted(t *testing.T) { + reader := deniedReader(t) + refusal := membershipLookupStatus("verify central org scope", auth.ErrVerifiedDatabaseIdentityRequired) + require.Equal(t, codes.Unauthenticated, status.Code(refusal)) + recordPolicyDenial(context.Background(), deniedMethod, denialReasonCentral, refusal) + require.Equal(t, int64(1), deniedPoints(t, reader)[deniedMethod+" "+denialReasonCentral]) +} + +// Nothing but a refusal is counted. The interceptor passes every handler error +// through recordPolicyDenial, so counting an Internal or an InvalidArgument +// would quietly turn an authorization metric into a general error rate — and an +// alert on it into noise nobody can act on. +func TestNonRefusalsAreNotCounted(t *testing.T) { + reader := deniedReader(t) + ctx := context.Background() + for _, err := range []error{ + nil, + status.Error(codes.Internal, "cannot verify membership"), + status.Error(codes.InvalidArgument, "org_id required"), + status.Error(codes.Unavailable, "authorization temporarily unavailable"), + connect.NewError(connect.CodeInternal, errors.New("boom")), + errors.New("an untyped error"), + } { + require.Equal(t, err, recordPolicyDenial(ctx, deniedMethod, denialReasonHandler, err)) + } + require.Empty(t, deniedPoints(t, reader)) +} diff --git a/module/services/accounts/code/pkg/adapters/rpc_policy_enforce_test.go b/module/services/accounts/code/pkg/adapters/rpc_policy_enforce_test.go index c96b7674f..7e4c4c3a2 100644 --- a/module/services/accounts/code/pkg/adapters/rpc_policy_enforce_test.go +++ b/module/services/accounts/code/pkg/adapters/rpc_policy_enforce_test.go @@ -52,9 +52,22 @@ func installEnforceService(t *testing.T, store business.Store) { } func enableEnforcement(t *testing.T) { + t.Helper() + setEnforcement(t, true) +} + +// shadowMode selects the explicit classify-only opt-out. It has to be set +// deliberately now that enforcement is the package's own default: a case that +// said nothing used to exercise shadow and now exercises enforce. +func shadowMode(t *testing.T) { + t.Helper() + setEnforcement(t, false) +} + +func setEnforcement(t *testing.T, enforce bool) { t.Helper() previous := centralEnforcement - centralEnforcement = true + centralEnforcement = enforce t.Cleanup(func() { centralEnforcement = previous }) } @@ -68,14 +81,26 @@ func enforceActorCtx() context.Context { return stampVerifiedIdentity(context.Background(), enforceActorID, enforceOrgID, auth.Assurance{}) } -// Shadow is the fallback: with enforcement off the interceptor changes no -// admission decision even for an org-admin method and a caller who is not a +// Shadow is the explicit opt-out: with enforcement off the interceptor changes +// no admission decision even for an org-admin method and a caller who is not a // member — the handler require* site remains the sole gate. func TestShadowModeDoesNotDenyAtAdmission(t *testing.T) { + shadowMode(t) installEnforceService(t, memberStore(gen.OrgRole_ORG_ROLE_MEMBER)) require.NoError(t, enforceCentralPolicy(enforceActorCtx(), orgAdminMethod)) } +// The package's own default is the enforcing one, so that a caller reaching an +// interceptor without going through startup — every test here, and any future +// embedding of these interceptors — exercises the same admission a deployment +// that configures nothing gets. The same case as above, with nothing set, +// denies. +func TestAdmissionEnforcesByDefault(t *testing.T) { + installEnforceService(t, memberStore(gen.OrgRole_ORG_ROLE_MEMBER)) + err := enforceCentralPolicy(enforceActorCtx(), orgAdminMethod) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + func TestEnforceAdmitsOrgAdmin(t *testing.T) { enableEnforcement(t) installEnforceService(t, memberStore(gen.OrgRole_ORG_ROLE_ADMIN)) diff --git a/module/services/accounts/code/pkg/business/rpc_policy_coverage.go b/module/services/accounts/code/pkg/business/rpc_policy_coverage.go index a62a12b61..218d4e982 100644 --- a/module/services/accounts/code/pkg/business/rpc_policy_coverage.go +++ b/module/services/accounts/code/pkg/business/rpc_policy_coverage.go @@ -93,7 +93,10 @@ func GlobalScopeEscapeHatchMethods() []string { // authenticated methods, the interceptor resolves the ORG_MEMBER / ORG_ADMIN // tenant against the caller's verified organization (CentralTenantEnforcement), // so a method whose floor reduces to that tenant — or to "a verified user" -// (tenant NONE or USER) — is fully covered. A declared permission (which may +// (tenant NONE or USER) — is fully covered. A shape-authenticated method is a +// gap: its caller may be a credential in the request body, so there is no +// verified organization at admission to resolve the tenant against, and the +// adapter resolves it from the credential itself. A declared permission (which may // admit a non-admin who holds it), a platform-role, an MFA requirement, an // org-owner or team tenant, a bound team resource, or a global-scope escape // method (globalScopeEscapeMethods) is a gap the require* handler sites still @@ -116,6 +119,9 @@ func ClassifyCentralCoverage(policy RPCPolicy) CentralCoverage { if hasGlobalScopeEscape(policy.FullMethod) { return CoverageGap } + if ShapeAuthenticated(policy.FullMethod) { + return CoverageGap + } for _, binding := range p.GetResourceBindings() { switch binding.GetTarget() { case policyv1.ResourceTarget_RESOURCE_TARGET_OWNED_RESOURCE: @@ -159,7 +165,8 @@ func ClassifyCentralCoverage(policy RPCPolicy) CentralCoverage { // resolves only the ORG_MEMBER / ORG_ADMIN tenant, against the caller's verified // organization, and only when the method declares no finer permission (which // could admit a non-admin who holds it), no platform-role or MFA requirement, -// no team or owned-resource binding, and no global-scope escape +// no team or owned-resource binding, is not shape-authenticated, and no +// global-scope escape // (globalScopeEscapeMethods, whose empty-org alternative the interceptor cannot // resolve). Every other requirement stays with the handler require* sites, so // this check is never stricter than the handler's own. @@ -181,6 +188,24 @@ func CentralTenantEnforcement(policy RPCPolicy) (policyv1.TenantRequirement, boo if hasGlobalScopeEscape(policy.FullMethod) { return policyv1.TenantRequirement_TENANT_REQUIREMENT_UNSPECIFIED, false } + // A shape-authenticated method (ShapeAuthenticated: the host interface's + // Issue and GetDelegation) carries its caller's credential in the request + // body, not necessarily as a bearer. Admission never reads the body, so + // there is no verified organization to resolve the declared tenant against: + // for a workload, an installation or a child hop there is no verified + // database scope at all, and enforcing the floor refuses every such call + // with "verified authentication context required" before the adapter can + // authenticate the credential it was handed and resolve the organization + // from it. The tenant stays with that adapter, which checks the + // organization against the credential rather than against a session. + // + // This is the exemption the enforce-by-default flip required: the floor has + // to decline a method whose authenticating credential it cannot see, and + // declining it here keeps the coverage classifier and this check agreeing on + // one answer per method. + if ShapeAuthenticated(policy.FullMethod) { + return policyv1.TenantRequirement_TENANT_REQUIREMENT_UNSPECIFIED, false + } if len(p.GetPermissions()) > 0 || p.GetPlatformRole() != policyv1.PlatformRoleRequirement_PLATFORM_ROLE_REQUIREMENT_NONE || p.GetMfa() != policyv1.MFARequirement_MFA_REQUIREMENT_NONE { diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 57714e12b..23fc66c9e 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -881,7 +881,14 @@ func doWork(ctx context.Context) (clean Clean, err error) { if err != nil { return nil, err } - adapters.SetCentralEnforcementMode(centralEnforcement) + if !centralEnforcement.Enforcing() { + // Say the opt-out by name, at boot, every boot. A deployment running the + // classify-only floor looks identical to one enforcing it until a call + // that should have been denied is served, so the only place this can be + // read is the boot log of the process that chose it. + w.Warn("RBAC_CENTRAL_ENFORCEMENT=shadow: request-time central RBAC admission classifies only and denies nothing; unset this key to enforce") + } + adapters.SetCentralEnforcementMode(centralEnforcement.Enforcing()) // /v1/status — public health probe surface. Probes run in parallel // with a 2s budget each; overall status is the worst result. The @@ -1589,18 +1596,39 @@ func configuredMFAStepUpMaxAge() (time.Duration, error) { return maxAge, nil } +// centralEnforcementMode is the request-time central RBAC admission a +// deployment selected. It is an enum rather than a bool so the boot can tell +// "nobody configured this" from "an operator asked for classify-only": the two +// now mean different things, and collapsing them onto an unset value is exactly +// how the classify-only reading became every deployment's default. +type centralEnforcementMode uint8 + +const ( + // centralEnforcementEnforce denies at admission. It is the default. + centralEnforcementEnforce centralEnforcementMode = iota + // centralEnforcementShadow classifies only and denies nothing. It is + // reachable only by naming it in the configuration. + centralEnforcementShadow +) + +// Enforcing reports whether the interceptors hard-deny. +func (m centralEnforcementMode) Enforcing() bool { return m == centralEnforcementEnforce } + // configuredCentralEnforcement selects request-time central RBAC admission from -// the security configuration. "enforce" turns hard-deny on; "shadow" (and an -// unset value) keeps the classify-only fallback. Any other value fails the boot -// so a typo cannot silently leave enforcement off. -func configuredCentralEnforcement() (bool, error) { +// the security configuration. An unset value enforces: the central tenant floor +// is a redundant floor beneath the per-call authority Check and the handlers' +// own require* sites, so a deployment that says nothing gets the stricter of the +// two readings rather than the weaker one. "shadow" is an explicit opt-out that +// the boot warns about by name; any other value fails the boot so a typo cannot +// silently leave enforcement off. +func configuredCentralEnforcement() (centralEnforcementMode, error) { switch strings.ToLower(strings.TrimSpace(workspaceEnv("security", "RBAC_CENTRAL_ENFORCEMENT"))) { - case "", "shadow": - return false, nil - case "enforce": - return true, nil + case "", "enforce": + return centralEnforcementEnforce, nil + case "shadow": + return centralEnforcementShadow, nil default: - return false, fmt.Errorf(`RBAC_CENTRAL_ENFORCEMENT must be "shadow" or "enforce"`) + return centralEnforcementEnforce, fmt.Errorf(`RBAC_CENTRAL_ENFORCEMENT must be "shadow" or "enforce"`) } } diff --git a/module/services/accounts/code/work_central_enforcement_test.go b/module/services/accounts/code/work_central_enforcement_test.go new file mode 100644 index 000000000..a63ed1f2c --- /dev/null +++ b/module/services/accounts/code/work_central_enforcement_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// clearCentralEnforcementEnvironment empties every carrier workspaceEnv reads +// RBAC_CENTRAL_ENFORCEMENT from. Both have to be blanked: `codefly ci run` +// injects the security group as CODEFLY__WORKSPACE_CONFIGURATION__SECURITY__* +// while a bare `go test` carries only the process environment, so a case that +// clears one of them asserts the default on one runner and an ambient value on +// the other. +func clearCentralEnforcementEnvironment(t *testing.T) { + t.Helper() + t.Setenv("RBAC_CENTRAL_ENFORCEMENT", "") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__SECURITY__RBAC_CENTRAL_ENFORCEMENT", "") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__SECURITY__RBAC_CENTRAL_ENFORCEMENT", "") +} + +// TestUnsetCentralEnforcementEnforces is the default this issue flipped: a +// deployment that configures nothing hard-denies at admission. It is asserted on +// the configuration reader rather than on the interceptor variable because that +// reader is the only place the default lives. +func TestUnsetCentralEnforcementEnforces(t *testing.T) { + clearCentralEnforcementEnvironment(t) + mode, err := configuredCentralEnforcement() + require.NoError(t, err) + require.Equal(t, centralEnforcementEnforce, mode) + require.True(t, mode.Enforcing()) +} + +func TestExplicitCentralEnforcementValues(t *testing.T) { + for _, testCase := range []struct { + value string + want centralEnforcementMode + }{ + {value: "enforce", want: centralEnforcementEnforce}, + {value: "ENFORCE", want: centralEnforcementEnforce}, + {value: " enforce ", want: centralEnforcementEnforce}, + {value: "shadow", want: centralEnforcementShadow}, + {value: "Shadow", want: centralEnforcementShadow}, + } { + t.Run(testCase.value, func(t *testing.T) { + clearCentralEnforcementEnvironment(t) + t.Setenv("RBAC_CENTRAL_ENFORCEMENT", testCase.value) + mode, err := configuredCentralEnforcement() + require.NoError(t, err) + require.Equal(t, testCase.want, mode) + }) + } +} + +// A typo must fail the boot rather than resolve to either mode. The returned +// mode is the enforcing one so a caller that ignored the error cannot end up +// shadowed, but the error is what the boot acts on. +func TestUnknownCentralEnforcementFailsBoot(t *testing.T) { + for _, value := range []string{"off", "true", "classify", "enforced", "no"} { + t.Run(value, func(t *testing.T) { + clearCentralEnforcementEnvironment(t) + t.Setenv("RBAC_CENTRAL_ENFORCEMENT", value) + mode, err := configuredCentralEnforcement() + require.Error(t, err) + require.Contains(t, err.Error(), "RBAC_CENTRAL_ENFORCEMENT") + require.True(t, mode.Enforcing()) + }) + } +} From b6a50b0635bca0b4d8d2fd8b6305c9554a5f6fb7 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:27:01 -0500 Subject: [PATCH 02/12] feat(accounts): signing out reaches the identity provider, both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ending the host session left the provider's session untouched, so the next sign-in through it was silent: a person pressed sign out, pressed sign in, and was back in without being asked anything. Both halves of OpenID Connect sign-out now exist, wired independently because providers offer them independently. RP-initiated logout: `Logout` answers `LogoutResponse.end_session_url` — the provider's `end_session_endpoint` with the deployment's registered `post_logout_redirect_uri`, and with the client's `id_token_hint` when it supplied one or the host's `client_id` when it did not, the substitution RP-Initiated Logout 1.0 §2 provides for exactly that case. The hint is client-supplied and no provider id_token is stored: the host has no other use for one, and retaining a provider credential to hand back later is a liability. The redirect is never read from the request, which would make this host's own sign-out an open redirect. An empty URL is the whole answer for a provider that publishes no endpoint, and for the development fixture; the endpoint comes from discovery or from an IDENTITY_END_SESSION_URL pin, and the boot says which, and says so when there is none. Back-channel logout: `POST /v1/auth/backchannel-logout` verifies a logout token against the provider's JWKS, reusing the sign-in validator's key cache, issuer and audience binding, and adds the checks that separate a logout token from every other token the same issuer signs — an events claim carrying the back-channel logout event, a sub or sid, no `nonce` (how §2.6 says to detect an id_token substituted for a logout token), and an iat within two minutes so a token given no `exp` is not replayable for ever. It then revokes every host session of the named subject, access half included. The route is mounted only when the configured provider can verify such a token, so a deployment without one exposes no such surface rather than one whose only honest answer is a refusal. A token for an unknown identity revokes nothing and answers 200, because whether this host knows a person must not be learnable from an unauthenticated response. A token naming only the provider's `sid` answers 501: nothing here records provider session ids, and 200 would claim sessions are gone when they are not. Replay needs no `jti` marker — revoking an already revoked session changes nothing, and the iat bound limits the window — so the endpoint has no table behind it. `PostgresStore.RevokeAllUserSessions` now returns the ids it revoked, as `RevokeSession` already did. Without them a whole-user sign-out was quietly weaker than a single-family one: the rows stopped refreshing while every outstanding access token stayed valid to its natural TTL, with no per-session revocation marker written. scripts/ci/check-generated-libraries.sh passes --force to `generate client`. That gate's job is to reproduce the checked-in tree, and the command refuses by default to re-point a library at a contract whose digest moved — exactly the case a reproduction check must handle — so after any proto change reaching that endpoint it failed with "use --force to overwrite" instead of with a diff, and the one thing it exists to detect became unreachable. Its git diff is what keeps overwriting safe. Co-Authored-By: Claude Opus 5 --- .../source-read-sdk/library.codefly.yaml | 2 +- module/FEATURES.md | 62 ++++- module/REST_SURFACE.md | 18 +- module/configurations/local/identity.env | 22 ++ .../api/accounts/authority/contract.binpb | Bin 1311879 -> 1313690 bytes .../saas/accounts/v1/authentication.proto | 30 ++- .../api/accounts/connect/contract.binpb | Bin 1311879 -> 1313690 bytes .../saas/accounts/v1/authentication.proto | 30 ++- module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- .../pkg/adapters/backchannel_logout_http.go | 130 ++++++++++ .../adapters/backchannel_logout_http_test.go | 205 +++++++++++++++ .../code/pkg/adapters/connect_handlers.go | 2 +- .../rest_refresh_cookie_chain_test.go | 7 +- .../accounts/code/pkg/adapters/rpcs.go | 7 +- .../accounts/code/pkg/auth/logout_token.go | 47 ++++ .../code/pkg/auth/oidc/backchannel.go | 119 +++++++++ .../code/pkg/auth/oidc/backchannel_test.go | 164 ++++++++++++ .../accounts/code/pkg/auth/oidc/discovery.go | 6 + .../accounts/code/pkg/business/auth.go | 19 +- .../code/pkg/business/provider_signout.go | 165 +++++++++++++ .../pkg/business/provider_signout_test.go | 132 ++++++++++ .../accounts/code/pkg/business/service.go | 1 + .../accounts/code/pkg/business/store.go | 2 +- .../authentication.connect.go | 18 +- .../gen/saas/accounts/v1/authentication.pb.go | 233 ++++++++++++------ .../accounts/v1/authentication_grpc.pb.go | 16 +- .../code/pkg/infra/postgres_sessions.go | 27 +- module/services/accounts/code/work.go | 66 +++++ .../accounts/generated/authz-methods.json | 2 +- .../generated/openapi-raw/api.swagger.json | 18 +- .../accounts/generated/service-catalog.json | 2 +- .../accounts/openapi/api.swagger.json | 18 +- .../saas/accounts/v1/authentication.proto | 30 ++- .../non-protobuf-extensions.rest.codefly.yaml | 20 ++ .../saas-sdk/generated/library.codefly.yaml | 2 +- .../gen/saas/accounts/v1/authentication_pb.ts | 76 ++++-- .../gen/saas/accounts/v1/authentication_pb.ts | 76 ++++-- scripts/ci/check-generated-libraries.sh | 11 +- 39 files changed, 1632 insertions(+), 161 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/backchannel_logout_http.go create mode 100644 module/services/accounts/code/pkg/adapters/backchannel_logout_http_test.go create mode 100644 module/services/accounts/code/pkg/auth/logout_token.go create mode 100644 module/services/accounts/code/pkg/auth/oidc/backchannel.go create mode 100644 module/services/accounts/code/pkg/auth/oidc/backchannel_test.go create mode 100644 module/services/accounts/code/pkg/business/provider_signout.go create mode 100644 module/services/accounts/code/pkg/business/provider_signout_test.go diff --git a/libraries/source-read-sdk/library.codefly.yaml b/libraries/source-read-sdk/library.codefly.yaml index 161cdad77..8e4b5e27f 100644 --- a/libraries/source-read-sdk/library.codefly.yaml +++ b/libraries/source-read-sdk/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: authority - contract-digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e + contract-digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 services: - ModuleCapabilitiesService generated-by: diff --git a/module/FEATURES.md b/module/FEATURES.md index 8cd3c0dc9..c9a1d23ec 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -211,9 +211,67 @@ binary (`grep -qF`) is how to tell without executing anything. | Rotation | One locked transaction consumes the family, resolves current authorization, and inserts one successor | | Org exchange | Authenticated target-only exchange; current membership/roles resolved under the active session lock; same device family and refresh credential | | Reuse defense | Reuse of a token consumed by rotation revokes every active user session; administrative revocation is not replay | -| Logout | The browser posts an empty body and its HttpOnly refresh cookie; the middleware moves the cookie's token into the request as it does for refresh, so the family is revoked without the credential ever reaching script. Deny-lists the presented access-token JTI and the verified session, so the access half dies on the strength of who is calling rather than of what the body carried | +| Logout | The browser posts an empty body and its HttpOnly refresh cookie; the middleware moves the cookie's token into the request as it does for refresh, so the family is revoked without the credential ever reaching script. Deny-lists the presented access-token JTI and the verified session, so the access half dies on the strength of who is calling rather than of what the body carried. The response carries the provider's RP-initiated logout URL when there is one — see [Signing out of the identity provider](#signing-out-of-the-identity-provider) | | Current state | Refresh requires current authorization; database triggers atomically revoke affected families on status, membership/role, platform-role, and verified-MFA changes | +### Signing out of the identity provider + +Ending the host session does not end the session at the identity provider. The +provider's own cookie is in the person's browser, so the next sign-in through it +is silent — the person presses "sign out", presses "sign in", and is back in +without being asked anything. Closing that is two independent halves, and a +provider offers each on its own, so each is wired on its own evidence. + +**RP-initiated logout (the host asks the client to finish).** `Logout` returns +`end_session_url`: the provider's `end_session_endpoint` with +`post_logout_redirect_uri`, and with the client's `id_token_hint` when it +supplied one or the host's `client_id` when it did not (OpenID Connect +RP-Initiated Logout 1.0 §2 allows that substitution precisely for the case where +no hint is available). The client sends the person's browser there after the +host session has ended. + +The hint is **client-supplied** and the host stores no provider `id_token`: it +has no other use for one, and retaining a provider credential to hand back later +is a liability rather than a feature. The redirect is **never** taken from the +request — the provider requires it to be registered, and reading it from the +caller would make this host's own sign-out an open redirect — so it is the +deployment's `IDENTITY_POST_LOGOUT_REDIRECT_URI`. + +`end_session_url` is empty when the provider publishes no `end_session_endpoint` +(and on the development fixture, which has no provider session at all). A client +reads an empty value as "nothing further to do", never as a failure. The +endpoint comes from the provider's discovery document, or from an explicit +`IDENTITY_END_SESSION_URL` pin for a provider configured from a preset rather +than from an issuer; the boot says which, and says so when there is none. + +**Back-channel logout (the provider tells the host).** `POST +/v1/auth/backchannel-logout` accepts an OpenID Connect back-channel logout token +and revokes every host session of the person it names, including each session's +access half. The route is unauthenticated at the perimeter and that is correct: +the caller is the provider's back channel, which holds no session here, and the +signed logout token *is* the credential — verified against the provider's JWKS +with the same key material, issuer and audience binding that admits a sign-in, +plus the checks that separate a logout token from every other token the same +issuer signs (an `events` claim carrying the back-channel logout event, a `sub` +or `sid`, **no** `nonce` — which is how an id_token substituted for a logout +token is detected — and an `iat` within two minutes, so a token the provider +gave no `exp` is not replayable for ever). + +The route is mounted only when the configured provider can verify such a token, +so a deployment without one exposes no such surface rather than one whose only +honest answer is a refusal. Two answers are deliberate and worth knowing: + +- a token for an identity this host has never seen revokes nothing and answers + **200**, because the provider is entitled to send one and whether this host + knows that person must not be learnable from the response; +- a token naming only the provider's `sid` answers **501**. Nothing here records + a provider session id, so no host session can be matched to it, and answering + 200 would tell the provider the sessions are gone when they are not. + +Replay is not tracked by `jti`: revoking an already-revoked session changes +nothing, so the operation is idempotent by construction and the `iat` bound is +what limits a token's life. There is no table behind this endpoint. + --- ## Authorization model @@ -305,6 +363,8 @@ their next owning change. | Session list + revoke | ✅ | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke | | Session lifetime policy | ✅ | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap | | Logout | ✅ | Revokes the presented device family, the presented access token and the verified session's access half; the browser's cookie is the credential carrier, so an empty body is a complete sign-out | +| RP-initiated logout | ✅ | `Logout` returns the provider's `end_session_endpoint` with `id_token_hint`/`client_id` and the deployment's registered `post_logout_redirect_uri`; empty when the provider publishes none | +| Back-channel logout | ✅ | `POST /v1/auth/backchannel-logout` verifies the provider's logout token against its JWKS and revokes every host session of the named subject, access half included. Mounted only when the provider can verify one. A `sid`-only token answers 501: provider session ids are not recorded | | OAuth state / CSRF | ✅ | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded | | OAuth PKCE | ❌ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) | | Account lockout (failed attempts) | ❌ | No counter on user table | diff --git a/module/REST_SURFACE.md b/module/REST_SURFACE.md index 3f024258b..90e6b4884 100644 --- a/module/REST_SURFACE.md +++ b/module/REST_SURFACE.md @@ -23,15 +23,19 @@ counted from `generated/rest-surface.json`: - 18 public routes and 157 authenticated routes; - 148 OpenAPI paths and 175 operations; - zero internal RPCs; -- fifteen explicit non-protobuf extensions loaded by auth-gateway: magic-link +- the explicit non-protobuf extensions loaded by auth-gateway: magic-link request/verification, the billing and Resend email webhooks, the two GitHub delivery receivers, the public status surface, the subscriptions stream, - checkout, free-plan, portal, and the four OAuth 2 authorization-server routes - (metadata, authorize validation, authorize grant, token). + checkout, free-plan, portal, the four OAuth 2 authorization-server routes + (metadata, authorize validation, authorize grant, token), and the OpenID + Connect back-channel logout receiver. -The generated runtime therefore authorizes 190 REST routes in total. Descriptor -routes and extensions remain separate so an extension can never be mistaken -for a protobuf procedure or inherit policy by path similarity. +Descriptor routes and extensions remain separate so an extension can never be +mistaken for a protobuf procedure or inherit policy by path similarity. The +extension list is deliberately not counted here, nor added to the descriptor +count for a total: nothing enforces either number, and two changes that each add +a route and each bump the same figure merge cleanly into a total that is +silently wrong. The file named below is the inventory. The extension count is the one number here a test holds: `TestGeneratedRESTSurfaceAndExtensions` in `auth-gateway/code/routing_rest.go`'s @@ -68,7 +72,7 @@ it trust. See [CALLABLE_SOURCES.md](./CALLABLE_SOURCES.md) for the wire contract | `services/accounts/generated/rest-surface.json` | Typed target-neutral REST catalog. | | `services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go` | Accounts registration and exact/template allowlist. | | `services/auth-gateway/code/routing_rest_catalog_gen.go` | Auth-gateway descriptor REST inventory. | -| `services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml` | Fifteen explicit routes without protobuf ownership. | +| `services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml` | The explicit routes without protobuf ownership. | | `services/accounts/openapi/api.swagger.json` | Checked-in public OpenAPI document. | The strict binding file covers every surface service exactly once. Twenty-six diff --git a/module/configurations/local/identity.env b/module/configurations/local/identity.env index aa90054af..2801c720e 100644 --- a/module/configurations/local/identity.env +++ b/module/configurations/local/identity.env @@ -46,3 +46,25 @@ IDENTITY_REGISTERED_CLIENTS= # never decided to admit. Whether to enable it is a deployment decision, not a # per-organisation one; see the handbook's decisions/registered-clients.md. IDENTITY_CLIENT_METADATA_DOCUMENTS= + +# IDENTITY_END_SESSION_URL pins the provider's OpenID Connect RP-initiated +# logout endpoint (`end_session_endpoint`). Unset means it is read from the +# provider's discovery document, exactly as IDENTITY_JWKS_URL and +# IDENTITY_TOKEN_URL are — so a standards-compliant provider configured by +# IDENTITY_ISSUER needs nothing here. A provider configured from a preset rather +# than from an issuer (auth0, google) has no discovery document to read, and +# needs this pin or it offers no RP-initiated sign-out. +# +# Unset on this profile because the local profile authenticates against a +# fixture, which has no provider session to end. +IDENTITY_END_SESSION_URL= + +# IDENTITY_POST_LOGOUT_REDIRECT_URI is where the identity provider returns the +# person's browser after ending its own session. It is sent as +# `post_logout_redirect_uri` on the RP-initiated logout URL and must be +# registered with the provider, which is why it is a deployment's own +# configuration and never taken from the sign-out request: a caller-supplied +# value would make this host's sign-out an open redirect. +# +# Unset means none is sent and the provider decides where the person lands. +IDENTITY_POST_LOGOUT_REDIRECT_URI= diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 257438d435624ec885abbb37d9bf4a00b58aba6b..559d627f42b72bdf85139b11e60276b19e43616e 100644 GIT binary patch delta 9059 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 I^y#Yq2b1er5C8xG delta 7217 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 APXGV_ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto index 182a5e694..a9ce38080 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto @@ -170,6 +170,31 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; + + // id_token_hint is the provider's id_token for the session being ended, as + // the client still holds it. It is optional and client-supplied on purpose: + // the host never retains a provider id_token — it has no other use for one, + // and storing a provider credential to hand back later is a liability, not a + // feature. When it is absent the end-session URL carries `client_id` instead, + // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + // where no hint is available. The provider may then ask the person to confirm + // which session they are ending. + string id_token_hint = 2; +} + +// LogoutResponse carries what the host cannot do on the person's behalf. The +// host ends its own session unconditionally; reaching the identity provider +// requires the person's own user agent, because the provider's session cookie +// is in their browser and not in any request accounts can make. +message LogoutResponse { + // end_session_url is the provider's `end_session_endpoint` with the + // RP-initiated logout parameters already applied, for the client to send the + // person's browser to after the host session has ended. Empty when this + // deployment's provider publishes no `end_session_endpoint`, or when the + // configured provider has no OIDC sign-out at all (the development fixture): + // an empty value is the whole answer, and a client must treat it as "nothing + // further to do" rather than as a failure. + string end_session_url = 1; } message JWKSResponse { @@ -458,7 +483,10 @@ service AuthService { }; } - rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + rpc Logout(LogoutRequest) returns (LogoutResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 257438d435624ec885abbb37d9bf4a00b58aba6b..559d627f42b72bdf85139b11e60276b19e43616e 100644 GIT binary patch delta 9059 zcmZ{pf0Pu}mB**vdsEX@&9A8eX6OMeF&cl&5KVISn5jmP+V)20TR-tey94vN>DAKgpg6rVU#zwW-@3q=oX+Q_l6DoO(U-Y{t}e|g z^;9PZmF`lp5_spkgM(sjdL^h-@}=VRo^qkZ;`;_do}$%hQ*ZMHr! zOZ=T{zv^cTh0>ggAN(}iQ7z0hMd`>^0`eA=1EN4}$$^D8Wb=jWi~{1F+OMvDS9UIi zTSVbzbKKoQxq=FSLr1ANGhgnCV~dl)>bZUv?vWulj&a?nwYZm@;jt;HwMJyz4Z;;_ zaOm0Fth(KnmFX2xD)EjmWae~cH9IbNN47|$b0{Ukp_n-`bmEMlGh3L+iMZ%3GzUVV z_CjOLDf)4sJw;@Y13v1surT*u(LLD?5Shj9(AFq3sRo1THY=r>4ijqYr2Ry3an)jCv}18&kQ?Jq=;}sA(7r@2k6Drnx(lVbT>y{5$I}S&VMZZe z=|rW_AoiAj0fm{v&?%I< z>GA%|{7-4WUx=ZEA_W}i4cS6Z5VM|xF&E%kWlm7WAOwkQG~4{Ho)A^8f*(o`{}q2U zx`0{{bEKz?M*&L-y*}D6XSIbXMC-EtOl(}5GkTw)=;(=Emea!f3?}y3E~|BYleO?w%o7xyMQkrIq8=5N@Bn)II~yJup25tC{`4!s5M_iF0^67+PX{e3fj=iQQT7e$Z-(1GC`$pp_XHdAB0( z2d&ocFfGrsU$N?5k7#-D^@^3suxpRAdW{}@lLcC^Jq5EVQ}JuVFL&m&LBS2LS1sYyhz)&wkIU`yBJ`&z4{NXq+?b-eFFCt)aymT>F*P;Pf52+?g!HCir;I zO5r4d*K-p;^nH5hAUqSMXTv&b)yJ2y`u(^@xQr*hPc<5>Z%)zj=A)K!JvT+SQ^KUR z9JM4vh%_KRVyQuHV+ib#245doYHR{)R&@%Qzeciv00uC>k|{3h1ib8rDzgV ziepp?9UDr4<(W@RY+MZQ-yybsVuFh+^brTgv9SbSA6rWIP?Hb{NZe?1dI0WK2ZjC%?;t-nAack5`69#Kw^w)$3OK|n6rG`X^ zSOTIGmO3XwB!?rk(-hh=YMLfI+=h9k*7Y4{5n_{w?X=~b6)%Uw)7Ckoqu3;3J8hkN z#>6IdY^SX+oiVYohS>g=AYuuwKBI7H3Jmq^n?&8sLQjFAc<4HXtsle}v3Q;sbx~}>?L5&wCN{C5=83ONbBCb6a4X^)LCPgl-FGZJc$jD3ChBe% zR_0~P|3S8p&*9`Uj^7SrduoTya?-WZP$yMOk9RhQ!|75JoTcX_T*-J#-J<)TU;cAIUwQ)AhoXy>o&HdB}ussFS%Pg|| zNyVVOn(wM@pWxh{#TDFmw{Ql=HzJ0+#Sq==&3s;8CPV{Mf$1q&y0>AmnbIR0CT?9O zFb?)mEm}N#xv2X^ZQYg5&aee5my48cycUjb1w}_=2Bv4jS|#+DfsNHG;*em%?+OZu zwlYk#=?dYk5_-&#z++~GXj&!on29vB>HQ)eGm!>gEAgJA8aJ;N(M*AfKvs*)gC-C?Q=qjzXaZp^L?jPzB$z35BLar6pX0?A1wu2WHwgqY z1=d;<2!Zbv(b=|zhf1%&*;W&wq0%c_*P1}0q0%cxuH`_+(Np88vIeJN9wCv0Kpr9@ z^a_k5?9hh=rpj?Dj|%GoJl~sM{8`cLu(m5UEhVs51QE4KXj6 zo(*eD#0xf-ZxGR80wx~4fjX0T!33`jqIpZiiv;eV4WeaBRJce(-8RN`BPrB{+Y}>= zmVwu1ffp6Eb0&pg`h?gDPn0Qa1k)!{dWXS;(e)L)-EPdz4{%_(v)O5_jkwx*C2Ym>c8Juud{Zlf-x*gNrl(-(n!>``o$-Ewi3jYY z{X%)Q@@9KBxfN?+wZ<3Oj5-`^aqF|xELZA#X@BgB=TIvq8CiM`!NR?}un8hp^vc~u z)t=}I8^zcw>JHX6e&4yOX|G5%xsPBX+I7@2`-J#oZO}E&x^SPcc_fey-}^)(HyW96 zwokNQL9Iik^6VFddR5qT#$n;f#dyoUDAKQ@`>=;%4Ke*z*k|{b;Q4pLKCi!oa_tv( zcs9Jb@tqA=cR&ofxc@pjNecVx{Uyg!FA4k7{*vpdmxcYc{!$u;N@0)bFAeb2?`c(% zQQK=my@8aPqVOMoVcTfWeods`Ku&RFBq~q6UOPO=X$t=!?7`IU2pUuKkPx5fekU8L zA3`HiImkphheXr)u?e7u#HACW$6EmUrcfUmpq65wZ;JGX1}JOr`j!DYO0WjkhZCYK zf#}<{CD%Gb!~YSfbC6P+Vj!mt6xjcY)TL2MT7-)~i_`@XAl3l;-2^sEfc;(qnDy%7 z#jJtY{~73lC3rrSFlPzOPYb*D&Z>c4_*p`ouQByABK5VX6p;uopVKi(cb68S-6%~d zSVJl1Nz?Z%p%gbu)AuZ)6!WDnMM%k0HmJod(lkCxD8;SPY!8+OdTN2RFYL!|B@(AuPzS;!%``JUOP6$c`#fmF%9&VkjQkY z^!Ya`;+3e*Qu)0?6oo`}?vm<$Qyrqx@s&hAa)nkVxcwQ#WjA04Yxkn~5 zhBcZo_ewKkSOe=?!kQ(p{#nABC9u9Pfyfe2FHcaj1k@{Nm7Xz_QMgj72Mkylfn6!n z4;ZjoMBA^Tl4#gk1K8EXRSz6^>y_du$uC&&Q{d1m?RaD>P3D8b7TTtA%PWeIrK(keY^SZc8FUR)>5_#rXW^XCz1 zc0#liE`Lc~uk+O+v|lAVfi+ZTeNr8kP@Tt;>ac|BJRa=?0uyb7Cs3U*>Jrvao()M6 zS;9`(nCt|Cv>BB&1yfPMHp}#rreIn`!L~@d%_I=bslKFO^`5;ol;St0V)a0~Rhm~s zJ@VZu%`2iF727J!E218;Y?JCaQ?Pnnux&E^oGBP<@cQ3L!B~Rl?OZT?Y5)<|P_bud znI_H>?T23{`++qO-;oe!3B-3M#90FIXA|NqVL$9j_5(`;ycueDvLPs?JyN}3@YY9o z_sH}M2Co)@cdxY1GSNnOpC`OW^_;Qsc}$9TYDfOV`7qosZT>cgY;9z>p^LsA9WB*@cdG;5m*A&m!(}37kgg#dud*? ze2w_NnshX4;QM;QmnHE11GSPKXe@#68%e9O1ipVv__BnC{!@b24w2fMQoU{X+7aJ3 zW%_NySBv2Lmb5R5_-YOF<}mSnTk~!3?02O&DP`t>75_g7J{apy++X;VjIXY3`08u( z_v4^;_%gRCOgM7%YmguwZKl%`k|HJva58n9CDf&;6uI>W9kB{-E z1cBdG%Jme2uZ*2G^{NTiOr1Dx?9_>q!fVEK<^0i8@O_#7>j9sbN9%97=!gbX_FZWY zh(;w<>0LQ6K14L2U*47H#Z$ZiP~Vg4FQ#7_biceO(|dHT22Rq)%8vpBzi}Axr3!kCJ`J68hw0y!hf%Gizv+Pm)HVl#WYv z!t_Z))F;Pf`h@8dEuv5UD(z8GpJ)wz@@X>W@U!xi6bn@RUP^((DH+=Q%MY|uNbZyz z#6P9T1m06}%r~R$m_jV4rMgAMhq9E$ds?P%QSqUSHMsqo!7EsT=g$(lEP?JBS{3Rs z#~R`NJi*Hv@ZPA*5sM|@y-DE}thXUcz&lTw(=JQEd$TepES5lbzQSu?A9IkAx>a4K zzH1!O>>@3FWrQ)syb;x{%AeqVD)9Z1Lv0SN@$U>h%9U%HH{T^Gc z(kqdNE+?t{du)ZWzx)LaJ$b*v?83(z`gP{ww4+3yl9_k#yWSt>jI8at-Z>DiQRcwp zBKbAS9GF~SU8An+i2`yF&_haXFj!rU^&yqsV6d_VtPd;mnqUc@*Ctq50@hzBI*#fC z6Ki~6dPJq#qfE4jn}4ay?*Z1p{m}#;OW?jD7rOW}B`dr6D9d3QpaHPGIZ&}Ip=_a!Qoq&F~U}@ms&&6LFe*^G05PyU4HyD3I@YjexuWxD4 I^y#Yq2b1er5C8xG delta 7217 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 APXGV_ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto index 182a5e694..a9ce38080 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto @@ -170,6 +170,31 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; + + // id_token_hint is the provider's id_token for the session being ended, as + // the client still holds it. It is optional and client-supplied on purpose: + // the host never retains a provider id_token — it has no other use for one, + // and storing a provider credential to hand back later is a liability, not a + // feature. When it is absent the end-session URL carries `client_id` instead, + // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + // where no hint is available. The provider may then ask the person to confirm + // which session they are ending. + string id_token_hint = 2; +} + +// LogoutResponse carries what the host cannot do on the person's behalf. The +// host ends its own session unconditionally; reaching the identity provider +// requires the person's own user agent, because the provider's session cookie +// is in their browser and not in any request accounts can make. +message LogoutResponse { + // end_session_url is the provider's `end_session_endpoint` with the + // RP-initiated logout parameters already applied, for the client to send the + // person's browser to after the host session has ended. Empty when this + // deployment's provider publishes no `end_session_endpoint`, or when the + // configured provider has no OIDC sign-out at all (the development fixture): + // an empty value is the whole answer, and a client must treat it as "nothing + // further to do" rather than as a failure. + string end_session_url = 1; } message JWKSResponse { @@ -458,7 +483,10 @@ service AuthService { }; } - rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + rpc Logout(LogoutRequest) returns (LogoutResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index e65f99eec..dd43f2055 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e", + "digest": "sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0", "services": [ { "name": "ModuleCapabilitiesService", @@ -66,7 +66,7 @@ "kind": "protobuf", "package": "", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e", + "digest": "sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0", "services": [ { "name": "ArtifactService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 75742ac6a..08ce1e3a9 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e + digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 - endpoint: connect kind: protobuf package: "" path: contracts/api/accounts/connect/contract.binpb - digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e + digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/code/pkg/adapters/backchannel_logout_http.go b/module/services/accounts/code/pkg/adapters/backchannel_logout_http.go new file mode 100644 index 000000000..8f6a43202 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/backchannel_logout_http.go @@ -0,0 +1,130 @@ +package adapters + +import ( + "errors" + "io" + "net/http" + "net/url" + + "github.com/codefly-dev/core/wool" + + "accounts/pkg/business" +) + +// The OpenID Connect back-channel logout receiver. +// +// A raw HTTP handler rather than a proto RPC for the same reason the OAuth +// surface is one: the shape IS the contract. Back-Channel Logout 1.0 §2.5 +// requires the relying party to accept +// `application/x-www-form-urlencoded` with a single `logout_token` parameter, +// and §2.7 requires a bare 200 on success and a 400 with no body of substance +// on a token it refuses. grpc-gateway produces neither, so a provider's own +// client — which is what calls this, not any code of ours — could not use a +// transcoded RPC. +// +// The endpoint is unauthenticated at the perimeter and that is correct: the +// caller is the identity provider's back channel, which holds no session, no +// bearer and no API key here. The signed logout token is the credential, and it +// is verified against the provider's JWKS with the same key material, issuer and +// audience binding that admits a sign-in. work.go mounts this route only when a +// provider that can verify a logout token is configured +// (business.Service.ProviderSignOutConfigured), so a deployment whose provider +// offers no back-channel logout — the development fixture, every provider with +// no JWKS — exposes no such surface at all rather than one whose only honest +// answer is a refusal. + +// BackchannelLogoutPath is where the provider posts a logout token. It is under +// `/v1/auth/` with a name no proto route uses: RegisterHTTPRoute matches by +// prefix and wins over the grpc-gateway mux, so the prefix must be narrow +// enough that it cannot shadow `/v1/auth/logout` or any other transcoded auth +// route, and it is registered as the exact path for that reason. +const BackchannelLogoutPath = "/v1/auth/backchannel-logout" + +// maxLogoutTokenBody bounds the body read. A logout token is a compact JWS of a +// few hundred bytes; this leaves room for a generous provider and refuses an +// unauthenticated caller streaming a body at an endpoint that needs no +// credential to reach. +const maxLogoutTokenBody = 16 << 10 + +// NewBackchannelLogoutHandler serves the provider's back-channel logout +// receiver. +func NewBackchannelLogoutHandler(svc *business.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wl := wool.Get(r.Context()).In("backchannelLogout") + if r.URL.Path != BackchannelLogoutPath { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + // §2.5: the request is a form POST. Anything else is not a + // back-channel logout request at all. + refuseBackchannelLogout(w, nil) + return + } + token, err := logoutTokenFromRequest(r) + if err != nil { + refuseBackchannelLogout(w, nil) + return + } + revoked, err := svc.BackchannelLogout(r.Context(), token) + switch { + case errors.Is(err, business.ErrProviderSessionNotResolvable): + // A verified request this host cannot act on. §2.7 says to answer + // 501 when the RP does not support the form of logout the token + // asks for, which is exactly this: the token named a provider + // session id and nothing here records one. Answering 200 would tell + // the provider the sessions are gone when they are not. + wl.Warn("a verified back-channel logout names only a provider session id, which this host cannot match", wool.ErrField(err)) + http.Error(w, "this relying party does not record provider session ids; send a sub claim", http.StatusNotImplemented) + return + case err != nil: + // Everything else — a bad signature, a token that is not a logout + // token, an unreachable JWKS, a failed revocation — is one answer. + // The reason is logged; an unauthenticated caller learns only that + // the token was not acted on, so a forgery cannot be refined + // against the response. + wl.Warn("back-channel logout refused", wool.ErrField(err)) + refuseBackchannelLogout(w, err) + return + } + // §2.7: a bare 200 with no cache. Zero revoked sessions is a success — + // the person has no live session here, which is the state the provider + // asked for, and a replayed token legitimately revokes nothing. + wl.Info("back-channel logout applied", wool.Field("sessions", revoked)) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + }) +} + +// logoutTokenFromRequest reads the single `logout_token` form parameter. The +// body is read under a limit and parsed here rather than through +// r.ParseForm, which would also fold in the URL query: a logout token is a +// credential and belongs in the body, where it does not land in an access log +// or a referrer. +func logoutTokenFromRequest(r *http.Request) (string, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxLogoutTokenBody+1)) + if err != nil { + return "", err + } + if len(body) > maxLogoutTokenBody { + return "", errors.New("backchannel logout: request body is too large to be a logout token") + } + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + token := values.Get("logout_token") + if token == "" { + return "", errors.New("backchannel logout: no logout_token parameter") + } + return token, nil +} + +// refuseBackchannelLogout answers §2.7's refusal: 400, no caching, and a body +// that names no cause. err is taken only to keep the call sites honest about +// having one to log; it is deliberately not written to the response. +func refuseBackchannelLogout(w http.ResponseWriter, _ error) { + w.Header().Set("Cache-Control", "no-store") + http.Error(w, "logout token not accepted", http.StatusBadRequest) +} diff --git a/module/services/accounts/code/pkg/adapters/backchannel_logout_http_test.go b/module/services/accounts/code/pkg/adapters/backchannel_logout_http_test.go new file mode 100644 index 000000000..fdc4ff85d --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/backchannel_logout_http_test.go @@ -0,0 +1,205 @@ +package adapters + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// backchannelStore answers the three reads and writes the back-channel path +// makes. Everything else is the embedded nil interface: a case that reached +// another method would panic rather than quietly pass. +type backchannelStore struct { + business.Store + user *gen.User + notFound bool + revoked []string + revokedAs string +} + +func (s *backchannelStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *backchannelStore) FindUserByIdentity(context.Context, string, string) (*gen.User, error) { + if s.notFound { + return nil, business.NewStoreError(errNoSuchIdentity, business.ErrTypeNotFound) + } + return s.user, nil +} + +func (s *backchannelStore) RevokeAllUserSessions(_ context.Context, userID, reason string) ([]string, error) { + s.revokedAs = reason + return s.revoked, nil +} + +var errNoSuchIdentity = &staticError{"identity not found"} + +type staticError struct{ message string } + +func (e *staticError) Error() string { return e.message } + +// logoutTokenVerifier admits one scripted token, standing in for the OIDC +// validator whose own refusals are covered in pkg/auth/oidc. +type logoutTokenVerifier struct { + token *auth.LogoutToken + err error +} + +func (v logoutTokenVerifier) ValidateLogoutToken(context.Context, string) (*auth.LogoutToken, error) { + if v.err != nil { + return nil, v.err + } + return v.token, nil +} + +func backchannelHandler(t *testing.T, store *backchannelStore, signOut business.ProviderSignOut) http.Handler { + t.Helper() + svc, err := business.NewService(store) + require.NoError(t, err) + svc.SetProviderSignOut(signOut) + return NewBackchannelLogoutHandler(svc) +} + +func postLogoutToken(handler http.Handler, body string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, BackchannelLogoutPath, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func subjectToken() *auth.LogoutToken { + return &auth.LogoutToken{ + Provider: "test-provider", + Subject: "user_01ABCXYZ", + ID: "logout-token-1", + IssuedAt: time.Now(), + } +} + +// Back-Channel Logout 1.0 §2.7: a bare 200, no caching, and the sessions gone. +func TestBackchannelLogoutRevokesAndAnswers200(t *testing.T) { + store := &backchannelStore{ + user: &gen.User{Uuid: "00000000-0000-7000-8000-00000000000a"}, + revoked: []string{"session-1", "session-2"}, + } + handler := backchannelHandler(t, store, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + response := postLogoutToken(handler, "logout_token=a.b.c") + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) + require.Equal(t, "backchannel_logout", store.revokedAs) +} + +// A provider subject this host has never seen revokes nothing and still +// succeeds. The provider is entitled to send the token, and whether this host +// knows that person must not be learnable from the response by an +// unauthenticated caller. +func TestBackchannelLogoutSucceedsForAnUnknownIdentity(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{notFound: true}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + require.Equal(t, http.StatusOK, postLogoutToken(handler, "logout_token=a.b.c").Code) +} + +// Idempotent by construction: a replayed token revokes an already-revoked set, +// which is zero rows, and that is a success. §2.7 requires the operation to be +// safely repeatable and this is why it needs no replay marker. +func TestBackchannelLogoutIsIdempotent(t *testing.T) { + store := &backchannelStore{user: &gen.User{Uuid: "00000000-0000-7000-8000-00000000000a"}} + handler := backchannelHandler(t, store, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + require.Equal(t, http.StatusOK, postLogoutToken(handler, "logout_token=a.b.c").Code) + require.Equal(t, http.StatusOK, postLogoutToken(handler, "logout_token=a.b.c").Code) +} + +// A refused token answers 400 with no cause. The endpoint needs no credential +// to reach, so a body naming why a forgery failed would let it be refined +// against the response. +func TestBackchannelLogoutRefusalNamesNoCause(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{err: auth.ErrNotALogoutToken}, + }) + + response := postLogoutToken(handler, "logout_token=a.b.c") + require.Equal(t, http.StatusBadRequest, response.Code) + require.Equal(t, "logout token not accepted\n", response.Body.String()) + require.NotContains(t, response.Body.String(), auth.ErrNotALogoutToken.Error()) + require.Equal(t, "no-store", response.Header().Get("Cache-Control")) +} + +// A verified token naming only a provider session id is a request this host +// cannot carry out. 501 says so; 200 would claim the sessions are gone. +func TestBackchannelLogoutAnswers501ForASessionOnlyToken(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: &auth.LogoutToken{ + Provider: "test-provider", SessionID: "provider-session-7", IssuedAt: time.Now(), + }}, + }) + + require.Equal(t, http.StatusNotImplemented, postLogoutToken(handler, "logout_token=a.b.c").Code) +} + +// The request shape §2.5 fixes, and only it: a form POST carrying +// logout_token. A GET, an empty body, a body with no such parameter and a body +// too large to be a logout token are each refused without reaching the +// verifier. +func TestBackchannelLogoutRefusesEveryOtherRequestShape(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + get := httptest.NewRequest(http.MethodGet, BackchannelLogoutPath, nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, get) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, http.MethodPost, recorder.Header().Get("Allow")) + + require.Equal(t, http.StatusBadRequest, postLogoutToken(handler, "").Code) + require.Equal(t, http.StatusBadRequest, postLogoutToken(handler, "id_token_hint=a.b.c").Code) + require.Equal(t, http.StatusBadRequest, postLogoutToken(handler, "logout_token="+strings.Repeat("x", 17<<10)).Code) +} + +// A logout token in the query string is not accepted. A credential there lands +// in access logs, proxy logs and referrers; §2.5 puts it in the body and so +// does this. +func TestBackchannelLogoutIgnoresAQueryStringToken(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + request := httptest.NewRequest(http.MethodPost, BackchannelLogoutPath+"?logout_token=a.b.c", strings.NewReader("")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} + +// The route is registered as an exact path and the handler holds it to one: +// RegisterHTTPRoute matches by prefix, so anything below the path must not be +// served from here. +func TestBackchannelLogoutServesOnlyItsOwnPath(t *testing.T) { + handler := backchannelHandler(t, &backchannelStore{}, business.ProviderSignOut{ + LogoutTokens: logoutTokenVerifier{token: subjectToken()}, + }) + + request := httptest.NewRequest(http.MethodPost, BackchannelLogoutPath+"/anything", strings.NewReader("logout_token=a.b.c")) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusNotFound, recorder.Code) +} diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index 8892bf1cd..a7a18b3a2 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -596,7 +596,7 @@ func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Requ func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) { return unary(ctx, req, h.inner.SwitchOrganization) } -func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { +func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[gen.LogoutResponse], error) { refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { req.Msg.RefreshToken = token }) diff --git a/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go b/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go index c0bd035f6..4697b2d8d 100644 --- a/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go +++ b/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go @@ -16,7 +16,6 @@ import ( "golang.org/x/net/http2/h2c" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - "google.golang.org/protobuf/types/known/emptypb" gen "accounts/pkg/gen/saas/accounts/v1" "accounts/pkg/gen/saas/accounts/v1/accountsv1connect" @@ -51,11 +50,11 @@ func (h *chainAuthHandler) RefreshToken(ctx context.Context, req *connect.Reques }) } -func (h *chainAuthHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { +func (h *chainAuthHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[gen.LogoutResponse], error) { refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { req.Msg.RefreshToken = token }) - return unary(ctx, req, func(_ context.Context, msg *gen.LogoutRequest) (*emptypb.Empty, error) { + return unary(ctx, req, func(_ context.Context, msg *gen.LogoutRequest) (*gen.LogoutResponse, error) { h.saw("logout", msg.GetRefreshToken()) - return &emptypb.Empty{}, nil + return &gen.LogoutResponse{}, nil }) } diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 474552939..62a650011 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1308,7 +1308,7 @@ func (s *AuthServer) BeginOAuth(ctx context.Context, req *gen.BeginOAuthRequest) return &gen.BeginOAuthResponse{State: state}, nil } -func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*emptypb.Empty, error) { +func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*gen.LogoutResponse, error) { if err := Validate(req); err != nil { return nil, err } @@ -1324,7 +1324,8 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty if sessionID, ok := auth.VerifiedSessionID(ctx); ok { verifiedSession = sessionID.String() } - if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil { + response, err := service.Logout(ctx, req, accessToken, verifiedSession) + if err != nil { // An incomplete sign-out is reported as retryable rather than as success: // the family is revoked, so nothing refreshes, but an already-issued access // token stays usable until it expires and the caller may want to retry. @@ -1334,7 +1335,7 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*empty } return nil, err } - return &emptypb.Empty{}, nil + return response, nil } // bearerFromContext extracts the bearer token from the incoming diff --git a/module/services/accounts/code/pkg/auth/logout_token.go b/module/services/accounts/code/pkg/auth/logout_token.go new file mode 100644 index 000000000..52ea91b33 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/logout_token.go @@ -0,0 +1,47 @@ +package auth + +import ( + "errors" + "time" +) + +// BackchannelLogoutEvent is the event name a logout token must carry, per +// OpenID Connect Back-Channel Logout 1.0 §2.4. A signed token from the right +// issuer that does not carry it is some other token of the provider's — an +// id_token, most dangerously — and admitting one would turn every sign-in into +// a sign-out request anybody holding the token could replay. +const BackchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + +// ErrNotALogoutToken reports a correctly-signed token from the configured +// issuer that is not a back-channel logout token, or that is one the +// specification refuses: no subject and no session id, a `nonce` claim (which +// only an id_token carries, and whose presence is how §2.6 says to detect an +// id_token substituted for a logout token), or an events claim without the +// back-channel logout member. +var ErrNotALogoutToken = errors.New("oidc: not a back-channel logout token") + +// LogoutToken is the admitted content of a back-channel logout token: who the +// provider is ending a session for, and the token's own identity for replay +// detection. It deliberately carries nothing else — a logout token's other +// claims say nothing this host acts on. +// +// It lives in this package, beside Claims, for the same reason Claims does: +// both are what a provider adapter hands the business layer, and the adapters +// (pkg/auth/oidc) already depend on the business layer, so a type the two +// exchange cannot live in either of them. +type LogoutToken struct { + // Provider is the configured provider name, the same value written to + // user_identities.provider, so the caller can resolve Subject without + // knowing which validator admitted the token. + Provider string + // Subject is the provider's `sub`: the person whose sessions end. Empty + // when the provider sent only a session id. + Subject string + // SessionID is the provider's `sid`. Empty when the provider sent only a + // subject. + SessionID string + // ID is the token's `jti`. Empty when the provider sent none. + ID string + // IssuedAt is the token's `iat`. + IssuedAt time.Time +} diff --git a/module/services/accounts/code/pkg/auth/oidc/backchannel.go b/module/services/accounts/code/pkg/auth/oidc/backchannel.go new file mode 100644 index 000000000..483a471fc --- /dev/null +++ b/module/services/accounts/code/pkg/auth/oidc/backchannel.go @@ -0,0 +1,119 @@ +package oidc + +import ( + "context" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "accounts/pkg/auth" +) + +// maxLogoutTokenAge bounds how old a logout token may be. The specification +// gives no lifetime and providers commonly omit `exp`, so a token with no +// expiry would otherwise be replayable for ever against an endpoint that needs +// no other credential. Two minutes is the same order as the clock skew the +// validator already tolerates on a sign-in token. +const maxLogoutTokenAge = 2 * time.Minute + +// ValidateLogoutToken verifies a back-channel logout token and returns what it +// asserts. It reuses the validator's JWKS cache, signing-algorithm allowlist, +// issuer and audience binding — the same key material and the same relying-party +// binding that admits a sign-in — and adds the five checks that separate a +// logout token from every other token the same issuer signs +// (OpenID Connect Back-Channel Logout 1.0 §2.6): +// +// 1. the signature, `iss` and `aud` bindings the validator already enforces; +// 2. an `events` claim carrying auth.BackchannelLogoutEvent; +// 3. a `sub`, a `sid`, or both — a token asserting neither names no session; +// 4. no `nonce` claim, which is how an id_token substituted for a logout token +// is detected; +// 5. an `iat` within maxLogoutTokenAge, so a token the provider gave no `exp` +// is not replayable for ever. +// +// Every refusal is one error kind to its caller: the endpoint this serves needs +// no credential beyond the token, so distinguishing "wrong signature" from "not +// a logout token" in the response would tell an unauthenticated caller how far +// its forgery got. +func (v *Validator) ValidateLogoutToken(ctx context.Context, token string) (*auth.LogoutToken, error) { + // `exp` is not required: §2.4 lists it as OPTIONAL, and the iat bound below + // is what actually limits the token's life. WithExpirationRequired here + // would refuse every provider that omits it. + opts := []jwt.ParserOption{ + jwt.WithValidMethods(v.cfg.AllowedAlgs), + jwt.WithIssuer(v.cfg.Issuer), + jwt.WithIssuedAt(), + jwt.WithLeeway(v.cfg.ClockSkew), + } + if v.cfg.Audience != "" { + opts = append(opts, jwt.WithAudience(v.cfg.Audience)) + } + claims := jwt.MapClaims{} + parsed, err := jwt.NewParser(opts...).ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + if !algAllowed(v.cfg.AllowedAlgs, t.Method.Alg()) { + return nil, auth.ErrNotALogoutToken + } + kid, _ := t.Header["kid"].(string) + return v.resolveKey(ctx, kid) + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", auth.ErrNotALogoutToken, err) + } + if !parsed.Valid { + return nil, auth.ErrNotALogoutToken + } + // The audience binding this validator may be configured with instead of + // `aud` (a provider whose tokens carry a non-standard client_id claim). It + // is applied here for the same reason it is applied to a sign-in token: a + // validator with no audience binding admits any correctly-signed token from + // the issuer, whichever relying party it was meant for, and New refuses to + // be built that way. + if v.cfg.ClientIDClaim != "" { + clientID, _ := claims[v.cfg.ClientIDClaim].(string) + if clientID == "" || clientID != v.cfg.ClientID { + return nil, fmt.Errorf("%w: audience binding", auth.ErrNotALogoutToken) + } + } + if !hasBackchannelLogoutEvent(claims["events"]) { + return nil, fmt.Errorf("%w: no %s event", auth.ErrNotALogoutToken, auth.BackchannelLogoutEvent) + } + if _, present := claims["nonce"]; present { + return nil, fmt.Errorf("%w: a nonce claim is an id_token's, not a logout token's", auth.ErrNotALogoutToken) + } + subject, _ := claims["sub"].(string) + sessionID, _ := claims["sid"].(string) + if subject == "" && sessionID == "" { + return nil, fmt.Errorf("%w: neither sub nor sid", auth.ErrNotALogoutToken) + } + issuedAtClaim, err := claims.GetIssuedAt() + if err != nil || issuedAtClaim == nil { + return nil, fmt.Errorf("%w: no iat", auth.ErrNotALogoutToken) + } + issuedAt := issuedAtClaim.Time + if time.Since(issuedAt) > maxLogoutTokenAge+v.cfg.ClockSkew { + return nil, fmt.Errorf("%w: issued %s ago", auth.ErrNotALogoutToken, time.Since(issuedAt).Round(time.Second)) + } + tokenID, _ := claims["jti"].(string) + return &auth.LogoutToken{ + Provider: v.cfg.ProviderName, + Subject: subject, + SessionID: sessionID, + ID: tokenID, + IssuedAt: issuedAt, + }, nil +} + +// hasBackchannelLogoutEvent reports whether the `events` claim carries the +// back-channel logout member. §2.4 requires the member's value to be an empty +// JSON object; a provider that puts something in it is still unambiguously +// requesting a back-channel logout, so the member's presence is what is +// checked and its contents are ignored rather than made a refusal. +func hasBackchannelLogoutEvent(events any) bool { + members, ok := events.(map[string]any) + if !ok { + return false + } + _, present := members[auth.BackchannelLogoutEvent] + return present +} diff --git a/module/services/accounts/code/pkg/auth/oidc/backchannel_test.go b/module/services/accounts/code/pkg/auth/oidc/backchannel_test.go new file mode 100644 index 000000000..706484e47 --- /dev/null +++ b/module/services/accounts/code/pkg/auth/oidc/backchannel_test.go @@ -0,0 +1,164 @@ +package oidc_test + +import ( + "context" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" + "accounts/pkg/auth/oidc" +) + +// validLogoutClaims is a back-channel logout token as OpenID Connect +// Back-Channel Logout 1.0 §2.4 specifies one: no `exp` (OPTIONAL there, and +// omitted by real providers), no `nonce`, and an events claim whose single +// member is the back-channel logout event with an empty object value. +func (f *fakeIdP) validLogoutClaims() jwt.MapClaims { + now := time.Now() + return jwt.MapClaims{ + "iss": f.issuer, + "aud": "test-audience", + "sub": "user_01ABCXYZ", + "sid": "provider-session-7", + "iat": now.Unix(), + "jti": "logout-token-1", + "events": map[string]any{auth.BackchannelLogoutEvent: map[string]any{}}, + } +} + +func TestLogoutTokenIsAdmittedWithItsSubjectAndSession(t *testing.T) { + idp := newFakeIdP(t) + validator := newValidator(t, idp) + + token, err := validator.ValidateLogoutToken(context.Background(), idp.sign(t, idp.validLogoutClaims())) + require.NoError(t, err) + require.Equal(t, "test-provider", token.Provider) + require.Equal(t, "user_01ABCXYZ", token.Subject) + require.Equal(t, "provider-session-7", token.SessionID) + require.Equal(t, "logout-token-1", token.ID) + require.WithinDuration(t, time.Now(), token.IssuedAt, time.Minute) +} + +// A token carrying only `sid` is admitted here: it is a valid logout token, and +// whether this host can act on one is the business layer's answer, not the +// verifier's. Refusing it here would conflate "not a logout token" with "a +// logout token naming something we do not record". +func TestLogoutTokenWithOnlyASessionIDIsAdmitted(t *testing.T) { + idp := newFakeIdP(t) + validator := newValidator(t, idp) + claims := idp.validLogoutClaims() + delete(claims, "sub") + + token, err := validator.ValidateLogoutToken(context.Background(), idp.sign(t, claims)) + require.NoError(t, err) + require.Empty(t, token.Subject) + require.Equal(t, "provider-session-7", token.SessionID) +} + +// The refusals, each mutated one at a time from an otherwise valid token, so a +// case cannot pass because an earlier check fired. The id_token case is the +// load-bearing one: an id_token is signed by the same key, names the same +// issuer and audience, and would otherwise make every sign-in a replayable +// sign-out for whoever holds the token. +func TestLogoutTokenRefusals(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(jwt.MapClaims) + }{ + {"an ordinary id_token, which carries a nonce", func(c jwt.MapClaims) { + c["nonce"] = "the-authorize-request-nonce" + }}, + {"no events claim at all", func(c jwt.MapClaims) { + delete(c, "events") + }}, + {"an events claim without the back-channel logout member", func(c jwt.MapClaims) { + c["events"] = map[string]any{"http://schemas.openid.net/event/something-else": map[string]any{}} + }}, + {"an events claim that is not an object", func(c jwt.MapClaims) { + c["events"] = auth.BackchannelLogoutEvent + }}, + {"neither a subject nor a session id", func(c jwt.MapClaims) { + delete(c, "sub") + delete(c, "sid") + }}, + {"another relying party's audience", func(c jwt.MapClaims) { + c["aud"] = "someone-elses-audience" + }}, + {"another issuer", func(c jwt.MapClaims) { + c["iss"] = "https://idp.test/other-tenant" + }}, + {"no iat, so nothing bounds its life", func(c jwt.MapClaims) { + delete(c, "iat") + }}, + {"an iat far in the past, which is a replay", func(c jwt.MapClaims) { + c["iat"] = time.Now().Add(-time.Hour).Unix() + }}, + {"an expiry already passed", func(c jwt.MapClaims) { + c["exp"] = time.Now().Add(-time.Minute).Unix() + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + idp := newFakeIdP(t) + validator := newValidator(t, idp) + claims := idp.validLogoutClaims() + testCase.mutate(claims) + + _, err := validator.ValidateLogoutToken(context.Background(), idp.sign(t, claims)) + require.ErrorIs(t, err, auth.ErrNotALogoutToken) + }) + } +} + +// A logout token signed by a key the provider's JWKS does not publish is +// refused as every other unverifiable token is. Signing with a second +// generated key under the first key's `kid` is the attack this closes: the +// header says a key the validator will fetch, and the signature is not that +// key's. +func TestLogoutTokenSignedByAnotherKeyIsRefused(t *testing.T) { + idp := newFakeIdP(t) + other := newFakeIdP(t) + other.issuer = idp.issuer + other.kid = idp.kid + validator := newValidator(t, idp) + + _, err := validator.ValidateLogoutToken(context.Background(), other.sign(t, idp.validLogoutClaims())) + require.ErrorIs(t, err, auth.ErrNotALogoutToken) +} + +// A provider whose tokens bind to the relying party through a non-standard +// client_id claim rather than through `aud` binds its logout tokens the same +// way, and a logout token that does not carry it is refused — a validator with +// no audience binding would admit any correctly-signed token from the issuer, +// which is the confusion oidc.New refuses to be constructed into. +func TestLogoutTokenHonoursAClientIDClaimBinding(t *testing.T) { + idp := newFakeIdP(t) + validator := newClientIDBoundValidator(t, idp) + + claims := idp.validLogoutClaims() + delete(claims, "aud") + _, err := validator.ValidateLogoutToken(context.Background(), idp.sign(t, claims)) + require.ErrorIs(t, err, auth.ErrNotALogoutToken) + + claims["client_id"] = "the-registered-client" + token, err := validator.ValidateLogoutToken(context.Background(), idp.sign(t, claims)) + require.NoError(t, err) + require.Equal(t, "user_01ABCXYZ", token.Subject) +} + +// newClientIDBoundValidator binds to the relying party through a non-standard +// `client_id` claim instead of `aud`, as a provider such as WorkOS does. +func newClientIDBoundValidator(t *testing.T, fi *fakeIdP) *oidc.Validator { + t.Helper() + v, err := oidc.New(oidc.Config{ + ProviderName: "test-provider", + Issuer: fi.issuer, + JWKSURL: fi.jwksURL(), + ClientIDClaim: "client_id", + ClientID: "the-registered-client", + }) + require.NoError(t, err) + return v +} diff --git a/module/services/accounts/code/pkg/auth/oidc/discovery.go b/module/services/accounts/code/pkg/auth/oidc/discovery.go index 7f85a0546..67bdbc6d0 100644 --- a/module/services/accounts/code/pkg/auth/oidc/discovery.go +++ b/module/services/accounts/code/pkg/auth/oidc/discovery.go @@ -27,6 +27,12 @@ type Discovery struct { TokenEndpoint string `json:"token_endpoint"` JWKSURI string `json:"jwks_uri"` UserInfoEndpoint string `json:"userinfo_endpoint"` + // EndSessionEndpoint is where a relying party sends the person's browser to + // end the session at the provider (OpenID Connect RP-Initiated Logout 1.0 + // §2). It is OPTIONAL in the metadata: a provider that publishes none offers + // no RP-initiated sign-out, which is a fact about that provider and not a + // configuration error, so Discover does not require it. + EndSessionEndpoint string `json:"end_session_endpoint"` } // DiscoveryURL derives the well-known metadata URL from an issuer, per the diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index 55a02516d..1b2943c09 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -498,11 +498,11 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string) // returned directly, so a failed access-marker write cannot discard the durable // half. Work that succeeded is kept — each step is idempotent, so a retry // completes the remainder rather than starting over. -func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error { +func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) (*gen.LogoutResponse, error) { w := wool.Get(ctx).In("Logout") if s.minter == nil { - return w.NewError("auth path not wired: minter missing") + return nil, w.NewError("auth path not wired: minter missing") } var incomplete []string if accessToken != "" { @@ -518,16 +518,23 @@ func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToke } } if err := s.minter.Revoke(ctx, req.RefreshToken); err != nil { - return err + return nil, err } if len(incomplete) > 0 { // The family is revoked, so nothing can be refreshed; what is still live is // the access half, until its natural expiry. The caller is told, because the // difference between "signed out" and "signed out except for a few minutes" // is theirs to act on. - return fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) - } - return nil + return nil, fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) + } + // The host session has ended. Ending the session at the identity provider + // needs the person's own browser — the provider's session cookie is there + // and in nothing accounts can send — so the complete answer is the URL for + // the client to send them to, and an empty one when this provider publishes + // no end_session_endpoint. It is computed only after every revocation held: + // a client told "now go here" while its host session is still live would + // sign out of the provider and stay signed in here. + return &gen.LogoutResponse{EndSessionUrl: s.endSessionURL(ctx, req.GetIdTokenHint())}, nil } // ErrLogoutIncomplete reports a sign-out whose refresh family was revoked while an diff --git a/module/services/accounts/code/pkg/business/provider_signout.go b/module/services/accounts/code/pkg/business/provider_signout.go new file mode 100644 index 000000000..abb0bef35 --- /dev/null +++ b/module/services/accounts/code/pkg/business/provider_signout.go @@ -0,0 +1,165 @@ +package business + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/codefly-dev/core/wool" + + "accounts/pkg/auth" +) + +// LogoutTokenVerifier admits an OpenID Connect back-channel logout token. The +// generic OIDC validator implements it; a provider adapter that publishes no +// JWKS implements none, and back-channel logout is then unavailable rather than +// unverified. +type LogoutTokenVerifier interface { + ValidateLogoutToken(ctx context.Context, token string) (*auth.LogoutToken, error) +} + +// ProviderSignOut is what this deployment's identity provider offers for ending +// a session at the provider, resolved once at startup. Both halves are +// independently optional, because providers offer them independently: a +// provider may publish an `end_session_endpoint` and no back-channel logout, or +// the reverse. +type ProviderSignOut struct { + // EndSessionEndpoint is the provider's RP-initiated logout endpoint, from + // its discovery document or an explicit configuration pin. Empty means the + // provider offers no RP-initiated sign-out. + EndSessionEndpoint string + // ClientID is this relying party's client id, sent as `client_id` when no + // `id_token_hint` is available (RP-Initiated Logout 1.0 §2). + ClientID string + // PostLogoutRedirectURI is where the provider returns the person's browser + // after ending its session. It is this host's own configured value, never a + // caller-supplied one: the provider requires it to be pre-registered, and + // taking it from the request would make the host's own sign-out an open + // redirect. Empty means none is sent, and the provider decides where the + // person lands. + PostLogoutRedirectURI string + // LogoutTokens verifies back-channel logout tokens. Nil means the endpoint + // is not mounted at all. + LogoutTokens LogoutTokenVerifier +} + +// SetProviderSignOut wires the provider's sign-out surface. Called once at +// startup, before any request is served. +func (s *Service) SetProviderSignOut(signOut ProviderSignOut) { s.providerSignOut = signOut } + +// ProviderSignOutConfigured reports whether a back-channel logout token can be +// verified on this deployment, which is what decides whether the endpoint is +// mounted. An endpoint that cannot verify a token must not exist: it would be +// an unauthenticated route whose only honest answer is a refusal. +func (s *Service) ProviderSignOutConfigured() bool { return s.providerSignOut.LogoutTokens != nil } + +// endSessionURL builds the provider's RP-initiated logout URL, or returns "" +// when this deployment has no `end_session_endpoint`. idTokenHint is the +// client-supplied provider id_token; when it is absent the `client_id` +// parameter identifies the relying party instead, which §2 permits precisely +// for that case. +// +// A malformed configured endpoint returns "" with the reason logged rather than +// an error to the caller: the host session has already ended by the time this +// runs, and failing the sign-out over a parse of operator configuration would +// turn a complete local sign-out into a reported failure. +func (s *Service) endSessionURL(ctx context.Context, idTokenHint string) string { + endpoint := strings.TrimSpace(s.providerSignOut.EndSessionEndpoint) + if endpoint == "" { + return "" + } + parsed, err := url.Parse(endpoint) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + wool.Get(ctx).In("endSessionURL").Warn("the configured end_session_endpoint is not an absolute URL; no RP-initiated logout URL is returned", + wool.Field("endpoint", endpoint)) + return "" + } + query := parsed.Query() + if hint := strings.TrimSpace(idTokenHint); hint != "" { + query.Set("id_token_hint", hint) + } else if clientID := strings.TrimSpace(s.providerSignOut.ClientID); clientID != "" { + query.Set("client_id", clientID) + } + if redirect := strings.TrimSpace(s.providerSignOut.PostLogoutRedirectURI); redirect != "" { + query.Set("post_logout_redirect_uri", redirect) + } + parsed.RawQuery = query.Encode() + return parsed.String() +} + +// ErrProviderSessionNotResolvable reports a verified back-channel logout token +// this host cannot act on: the provider named only its own session id (`sid`), +// and nothing here records provider session ids, so no host session can be +// matched to it. It is a verified request the host must answer honestly rather +// than acknowledge as done. +var ErrProviderSessionNotResolvable = errors.New("backchannel logout: the token names only a provider session id, which this host does not record") + +// BackchannelLogout verifies an OpenID Connect back-channel logout token and +// ends every host session of the person it names. It returns how many sessions +// it revoked; zero is a success — a person with no live session here is exactly +// the state the provider is asking for, and the endpoint is required to be +// idempotent, so a replayed token revokes nothing and still succeeds. +// +// The token's `sub` is the provider subject, resolved through the same +// (provider, provider_id) pair sign-in writes, so a token for an identity this +// host has never seen revokes nothing rather than erroring: the provider is +// entitled to send one, and whether this host knows that person is not +// something an unauthenticated caller should be able to learn from the +// response. +// +// Replay is deliberately not tracked by `jti`. The operation is idempotent by +// construction — revoking an already-revoked session changes nothing — and the +// verifier bounds a token's age, so a replay window needs no durable marker +// and this endpoint needs no table. +func (s *Service) BackchannelLogout(ctx context.Context, rawToken string) (int, error) { + w := wool.Get(ctx).In("BackchannelLogout") + if s.providerSignOut.LogoutTokens == nil { + return 0, fmt.Errorf("backchannel logout: no identity provider on this deployment verifies logout tokens") + } + token, err := s.providerSignOut.LogoutTokens.ValidateLogoutToken(ctx, rawToken) + if err != nil { + return 0, err + } + if token.Subject == "" { + w.Warn("a verified logout token named only a provider session id", + wool.Field("provider", token.Provider), wool.Field("jti", token.ID)) + return 0, ErrProviderSessionNotResolvable + } + var revoked []string + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + // The provider subject is not the caller's own identity — there is no + // caller identity at all on this path — so the lookup and the write both + // run under the control plane, as every other cross-user session write + // does. RLS would otherwise scope `sessions` and `user_identities` to a + // request principal this path does not have. + user, err := s.store.FindUserByIdentity(ctx, token.Provider, token.Subject) + if err != nil { + var storeErr *StoreError + if errors.As(err, &storeErr) && storeErr.StoreErrorType == ErrTypeNotFound { + return nil + } + return err + } + revoked, err = s.store.RevokeAllUserSessions(ctx, user.Uuid, "backchannel_logout") + return err + }); err != nil { + return 0, w.Wrapf(err, "revoke sessions for a provider back-channel logout") + } + // Revoking the rows ends refreshing; the access half dies at its natural + // TTL unless each revoked session is marked. Best-effort for the same + // reason the platform-admin revoke is: the durable rows above are the + // authority, and a revocation-store outage must bound the residual exposure + // rather than discard a sign-out the provider already considers done. + if s.minter != nil { + for _, sessionID := range revoked { + if err := s.minter.RevokeSessionAccess(ctx, sessionID); err != nil { + w.Warn("RevokeSessionAccess failed (best-effort)", wool.ErrField(err)) + } + } + } + w.Info("provider back-channel logout applied", + wool.Field("provider", token.Provider), wool.Field("sessions", len(revoked))) + return len(revoked), nil +} diff --git a/module/services/accounts/code/pkg/business/provider_signout_test.go b/module/services/accounts/code/pkg/business/provider_signout_test.go new file mode 100644 index 000000000..9a19e0e74 --- /dev/null +++ b/module/services/accounts/code/pkg/business/provider_signout_test.go @@ -0,0 +1,132 @@ +package business + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "accounts/pkg/auth" +) + +const testEndSession = "https://idp.example.com/oidc/logout" + +func signOutService(signOut ProviderSignOut) *Service { + return &Service{providerSignOut: signOut} +} + +// The id_token_hint the client supplied is what identifies the session to the +// provider, and the configured post-logout redirect travels with it. The host +// never stores a provider id_token, so a client that has one is the only source +// of the hint. +func TestEndSessionURLCarriesTheClientSuppliedHint(t *testing.T) { + svc := signOutService(ProviderSignOut{ + EndSessionEndpoint: testEndSession, + ClientID: "acme-host", + PostLogoutRedirectURI: "https://app.example.com/signed-out", + }) + + parsed, err := url.Parse(svc.endSessionURL(context.Background(), "the-provider-id-token")) + require.NoError(t, err) + require.Equal(t, "https://idp.example.com/oidc/logout", parsed.Scheme+"://"+parsed.Host+parsed.Path) + require.Equal(t, "the-provider-id-token", parsed.Query().Get("id_token_hint")) + require.Equal(t, "https://app.example.com/signed-out", parsed.Query().Get("post_logout_redirect_uri")) + // RP-Initiated Logout 1.0 §2: client_id is for the case where no hint is + // available. Sending both is allowed but says nothing more, so the hint + // alone identifies the request. + require.Empty(t, parsed.Query().Get("client_id")) +} + +// With no hint, `client_id` identifies the relying party instead — the +// substitution §2 provides for exactly this case. A client that lost its +// id_token still reaches the provider's sign-out. +func TestEndSessionURLFallsBackToClientID(t *testing.T) { + svc := signOutService(ProviderSignOut{EndSessionEndpoint: testEndSession, ClientID: "acme-host"}) + + parsed, err := url.Parse(svc.endSessionURL(context.Background(), "")) + require.NoError(t, err) + require.Equal(t, "acme-host", parsed.Query().Get("client_id")) + require.Empty(t, parsed.Query().Get("id_token_hint")) + require.Empty(t, parsed.Query().Get("post_logout_redirect_uri")) +} + +// A query the operator already put on the configured endpoint survives: some +// providers publish an end_session_endpoint carrying a tenant parameter, and +// replacing its query would break every sign-out on such a deployment. +func TestEndSessionURLPreservesTheConfiguredQuery(t *testing.T) { + svc := signOutService(ProviderSignOut{ + EndSessionEndpoint: testEndSession + "?tenant=acme", + ClientID: "acme-host", + }) + + parsed, err := url.Parse(svc.endSessionURL(context.Background(), "")) + require.NoError(t, err) + require.Equal(t, "acme", parsed.Query().Get("tenant")) + require.Equal(t, "acme-host", parsed.Query().Get("client_id")) +} + +// No endpoint, and a configured endpoint that is not an absolute URL, both +// answer "". The host session has already ended by the time this runs, so a +// bad configuration must not turn a complete local sign-out into a reported +// failure — it is a warning at the log and an empty answer to the client, +// which the contract says to read as "nothing further to do". +func TestEndSessionURLIsEmptyWithoutAUsableEndpoint(t *testing.T) { + for _, endpoint := range []string{"", " ", "not-a-url", "/oidc/logout", "https://"} { + svc := signOutService(ProviderSignOut{EndSessionEndpoint: endpoint, ClientID: "acme-host"}) + require.Empty(t, svc.endSessionURL(context.Background(), "a-hint"), "endpoint %q", endpoint) + } +} + +// The development fixture provider has no OIDC sign-out: work.go wires a +// ProviderSignOut only on the real-provider path, so a fixture deployment +// reaches here with nothing set. A sign-out then ends the host session and +// returns no URL, and the back-channel endpoint is not mounted at all. +func TestTheFixtureProviderOffersNoProviderSignOut(t *testing.T) { + svc := signOutService(ProviderSignOut{}) + require.Empty(t, svc.endSessionURL(context.Background(), "a-hint")) + require.False(t, svc.ProviderSignOutConfigured()) + + _, err := svc.BackchannelLogout(context.Background(), "any.logout.token") + require.Error(t, err) + require.Contains(t, err.Error(), "verifies logout tokens") +} + +// A verified token that names only the provider's own session id is a request +// this host cannot match: nothing here records a provider `sid`. It answers so +// rather than acknowledging a sign-out that did not happen. +func TestBackchannelLogoutRefusesASessionOnlyToken(t *testing.T) { + svc := signOutService(ProviderSignOut{LogoutTokens: fixedLogoutToken{token: &auth.LogoutToken{ + Provider: "test-provider", SessionID: "provider-session-7", IssuedAt: time.Now(), + }}}) + + _, err := svc.BackchannelLogout(context.Background(), "a.logout.token") + require.ErrorIs(t, err, ErrProviderSessionNotResolvable) +} + +// A token the verifier refuses never reaches the store. +func TestBackchannelLogoutSurfacesAVerifierRefusal(t *testing.T) { + svc := signOutService(ProviderSignOut{LogoutTokens: fixedLogoutToken{err: auth.ErrNotALogoutToken}}) + + _, err := svc.BackchannelLogout(context.Background(), "a.logout.token") + require.ErrorIs(t, err, auth.ErrNotALogoutToken) +} + +type fixedLogoutToken struct { + token *auth.LogoutToken + err error +} + +func (f fixedLogoutToken) ValidateLogoutToken(context.Context, string) (*auth.LogoutToken, error) { + if f.err != nil { + return nil, f.err + } + return f.token, nil +} + +// The interface the business layer declares is the one the OIDC validator +// satisfies. Asserted here because the only production assignment is a type +// switch in work.go, which would silently stop matching rather than fail to +// compile. +var _ LogoutTokenVerifier = fixedLogoutToken{} diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index cbf7602c7..eacaee6b3 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -41,6 +41,7 @@ type Service struct { devValidator auth.TokenValidator // development only: allowlists explicit fixture identities resolver auth.IdentityResolver minter auth.JWTMinter + providerSignOut ProviderSignOut // what the identity provider offers for ending its own session emailOutbox *email.Outbox // optional durable email producer; transport is worker-only billing BillingClient // optional: Stripe client for checkout/portal billingURLs BillingRedirects // server-owned Stripe return destinations diff --git a/module/services/accounts/code/pkg/business/store.go b/module/services/accounts/code/pkg/business/store.go index f86c20b6d..8bb99aa42 100644 --- a/module/services/accounts/code/pkg/business/store.go +++ b/module/services/accounts/code/pkg/business/store.go @@ -671,7 +671,7 @@ type Store interface { // session-revocation marker per id and kill the access half immediately. RevokeSession(ctx context.Context, deviceSessionID string, reason string) ([]string, error) RevokeSessionFamily(ctx context.Context, familyID string, reason string) error - RevokeAllUserSessions(ctx context.Context, userID string, reason string) error + RevokeAllUserSessions(ctx context.Context, userID string, reason string) ([]string, error) UpdateSessionActivity(ctx context.Context, sessionID string) error // Webhooks diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go index 1a567f5d2..bb5c5ad8d 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go @@ -90,7 +90,10 @@ type AuthServiceClient interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *connect.Request[v1.SwitchOrganizationRequest]) (*connect.Response[v1.SwitchOrganizationResponse], error) - Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) GetJWKS(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -164,7 +167,7 @@ func NewAuthServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(authServiceMethods.ByName("SwitchOrganization")), connect.WithClientOptions(opts...), ), - logout: connect.NewClient[v1.LogoutRequest, emptypb.Empty]( + logout: connect.NewClient[v1.LogoutRequest, v1.LogoutResponse]( httpClient, baseURL+AuthServiceLogoutProcedure, connect.WithSchema(authServiceMethods.ByName("Logout")), @@ -206,7 +209,7 @@ type authServiceClient struct { completeWebAuthnMFAChallenge *connect.Client[v1.CompleteWebAuthnMFAChallengeRequest, v1.CompleteMFAChallengeResponse] refreshToken *connect.Client[v1.RefreshTokenRequest, v1.RefreshTokenResponse] switchOrganization *connect.Client[v1.SwitchOrganizationRequest, v1.SwitchOrganizationResponse] - logout *connect.Client[v1.LogoutRequest, emptypb.Empty] + logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse] getJWKS *connect.Client[emptypb.Empty, v1.JWKSResponse] validateClientAuthorization *connect.Client[v1.ValidateClientAuthorizationRequest, v1.ValidateClientAuthorizationResponse] issueClientAuthorizationCode *connect.Client[v1.IssueClientAuthorizationCodeRequest, v1.IssueClientAuthorizationCodeResponse] @@ -249,7 +252,7 @@ func (c *authServiceClient) SwitchOrganization(ctx context.Context, req *connect } // Logout calls saas.accounts.v1.AuthService.Logout. -func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { +func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { return c.logout.CallUnary(ctx, req) } @@ -293,7 +296,10 @@ type AuthServiceHandler interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *connect.Request[v1.SwitchOrganizationRequest]) (*connect.Response[v1.SwitchOrganizationResponse], error) - Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) GetJWKS(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -456,7 +462,7 @@ func (UnimplementedAuthServiceHandler) SwitchOrganization(context.Context, *conn return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.SwitchOrganization is not implemented")) } -func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { +func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.Logout is not implemented")) } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go index 64645e217..7a299030d 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go @@ -926,8 +926,17 @@ func (x *SwitchOrganizationResponse) GetExpiresIn() int64 { } type LogoutRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + // id_token_hint is the provider's id_token for the session being ended, as + // the client still holds it. It is optional and client-supplied on purpose: + // the host never retains a provider id_token — it has no other use for one, + // and storing a provider credential to hand back later is a liability, not a + // feature. When it is absent the end-session URL carries `client_id` instead, + // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + // where no hint is available. The provider may then ask the person to confirm + // which session they are ending. + IdTokenHint string `protobuf:"bytes,2,opt,name=id_token_hint,json=idTokenHint,proto3" json:"id_token_hint,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -969,6 +978,68 @@ func (x *LogoutRequest) GetRefreshToken() string { return "" } +func (x *LogoutRequest) GetIdTokenHint() string { + if x != nil { + return x.IdTokenHint + } + return "" +} + +// LogoutResponse carries what the host cannot do on the person's behalf. The +// host ends its own session unconditionally; reaching the identity provider +// requires the person's own user agent, because the provider's session cookie +// is in their browser and not in any request accounts can make. +type LogoutResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // end_session_url is the provider's `end_session_endpoint` with the + // RP-initiated logout parameters already applied, for the client to send the + // person's browser to after the host session has ended. Empty when this + // deployment's provider publishes no `end_session_endpoint`, or when the + // configured provider has no OIDC sign-out at all (the development fixture): + // an empty value is the whole answer, and a client must treat it as "nothing + // further to do" rather than as a failure. + EndSessionUrl string `protobuf:"bytes,1,opt,name=end_session_url,json=endSessionUrl,proto3" json:"end_session_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutResponse) Reset() { + *x = LogoutResponse{} + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutResponse) ProtoMessage() {} + +func (x *LogoutResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. +func (*LogoutResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{15} +} + +func (x *LogoutResponse) GetEndSessionUrl() string { + if x != nil { + return x.EndSessionUrl + } + return "" +} + type JWKSResponse struct { state protoimpl.MessageState `protogen:"open.v1"` KeysJson string `protobuf:"bytes,1,opt,name=keys_json,json=keysJson,proto3" json:"keys_json,omitempty"` @@ -978,7 +1049,7 @@ type JWKSResponse struct { func (x *JWKSResponse) Reset() { *x = JWKSResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -990,7 +1061,7 @@ func (x *JWKSResponse) String() string { func (*JWKSResponse) ProtoMessage() {} func (x *JWKSResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1003,7 +1074,7 @@ func (x *JWKSResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use JWKSResponse.ProtoReflect.Descriptor instead. func (*JWKSResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{15} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{16} } func (x *JWKSResponse) GetKeysJson() string { @@ -1028,7 +1099,7 @@ type BeginOAuthRequest struct { func (x *BeginOAuthRequest) Reset() { *x = BeginOAuthRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1040,7 +1111,7 @@ func (x *BeginOAuthRequest) String() string { func (*BeginOAuthRequest) ProtoMessage() {} func (x *BeginOAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1053,7 +1124,7 @@ func (x *BeginOAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginOAuthRequest.ProtoReflect.Descriptor instead. func (*BeginOAuthRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{16} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{17} } func (x *BeginOAuthRequest) GetProvider() string { @@ -1081,7 +1152,7 @@ type BeginOAuthResponse struct { func (x *BeginOAuthResponse) Reset() { *x = BeginOAuthResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1093,7 +1164,7 @@ func (x *BeginOAuthResponse) String() string { func (*BeginOAuthResponse) ProtoMessage() {} func (x *BeginOAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1106,7 +1177,7 @@ func (x *BeginOAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginOAuthResponse.ProtoReflect.Descriptor instead. func (*BeginOAuthResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{17} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{18} } func (x *BeginOAuthResponse) GetState() string { @@ -1134,7 +1205,7 @@ type ClientAuthorizationRequest struct { func (x *ClientAuthorizationRequest) Reset() { *x = ClientAuthorizationRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1146,7 +1217,7 @@ func (x *ClientAuthorizationRequest) String() string { func (*ClientAuthorizationRequest) ProtoMessage() {} func (x *ClientAuthorizationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1159,7 +1230,7 @@ func (x *ClientAuthorizationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientAuthorizationRequest.ProtoReflect.Descriptor instead. func (*ClientAuthorizationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{18} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{19} } func (x *ClientAuthorizationRequest) GetClientId() string { @@ -1199,7 +1270,7 @@ type ValidateClientAuthorizationRequest struct { func (x *ValidateClientAuthorizationRequest) Reset() { *x = ValidateClientAuthorizationRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1211,7 +1282,7 @@ func (x *ValidateClientAuthorizationRequest) String() string { func (*ValidateClientAuthorizationRequest) ProtoMessage() {} func (x *ValidateClientAuthorizationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1224,7 +1295,7 @@ func (x *ValidateClientAuthorizationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ValidateClientAuthorizationRequest.ProtoReflect.Descriptor instead. func (*ValidateClientAuthorizationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{19} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{20} } func (x *ValidateClientAuthorizationRequest) GetAuthorization() *ClientAuthorizationRequest { @@ -1246,7 +1317,7 @@ type ValidateClientAuthorizationResponse struct { func (x *ValidateClientAuthorizationResponse) Reset() { *x = ValidateClientAuthorizationResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1258,7 +1329,7 @@ func (x *ValidateClientAuthorizationResponse) String() string { func (*ValidateClientAuthorizationResponse) ProtoMessage() {} func (x *ValidateClientAuthorizationResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1271,7 +1342,7 @@ func (x *ValidateClientAuthorizationResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use ValidateClientAuthorizationResponse.ProtoReflect.Descriptor instead. func (*ValidateClientAuthorizationResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{20} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{21} } func (x *ValidateClientAuthorizationResponse) GetClientName() string { @@ -1290,7 +1361,7 @@ type IssueClientAuthorizationCodeRequest struct { func (x *IssueClientAuthorizationCodeRequest) Reset() { *x = IssueClientAuthorizationCodeRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1302,7 +1373,7 @@ func (x *IssueClientAuthorizationCodeRequest) String() string { func (*IssueClientAuthorizationCodeRequest) ProtoMessage() {} func (x *IssueClientAuthorizationCodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1315,7 +1386,7 @@ func (x *IssueClientAuthorizationCodeRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use IssueClientAuthorizationCodeRequest.ProtoReflect.Descriptor instead. func (*IssueClientAuthorizationCodeRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{21} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{22} } func (x *IssueClientAuthorizationCodeRequest) GetAuthorization() *ClientAuthorizationRequest { @@ -1337,7 +1408,7 @@ type IssueClientAuthorizationCodeResponse struct { func (x *IssueClientAuthorizationCodeResponse) Reset() { *x = IssueClientAuthorizationCodeResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1349,7 +1420,7 @@ func (x *IssueClientAuthorizationCodeResponse) String() string { func (*IssueClientAuthorizationCodeResponse) ProtoMessage() {} func (x *IssueClientAuthorizationCodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1362,7 +1433,7 @@ func (x *IssueClientAuthorizationCodeResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use IssueClientAuthorizationCodeResponse.ProtoReflect.Descriptor instead. func (*IssueClientAuthorizationCodeResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{22} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{23} } func (x *IssueClientAuthorizationCodeResponse) GetCode() string { @@ -1393,7 +1464,7 @@ type AuthorizationCodeGrant struct { func (x *AuthorizationCodeGrant) Reset() { *x = AuthorizationCodeGrant{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1405,7 +1476,7 @@ func (x *AuthorizationCodeGrant) String() string { func (*AuthorizationCodeGrant) ProtoMessage() {} func (x *AuthorizationCodeGrant) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1418,7 +1489,7 @@ func (x *AuthorizationCodeGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizationCodeGrant.ProtoReflect.Descriptor instead. func (*AuthorizationCodeGrant) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{23} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{24} } func (x *AuthorizationCodeGrant) GetCode() string { @@ -1454,7 +1525,7 @@ type ClientRefreshTokenGrant struct { func (x *ClientRefreshTokenGrant) Reset() { *x = ClientRefreshTokenGrant{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1466,7 +1537,7 @@ func (x *ClientRefreshTokenGrant) String() string { func (*ClientRefreshTokenGrant) ProtoMessage() {} func (x *ClientRefreshTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1479,7 +1550,7 @@ func (x *ClientRefreshTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientRefreshTokenGrant.ProtoReflect.Descriptor instead. func (*ClientRefreshTokenGrant) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{24} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{25} } func (x *ClientRefreshTokenGrant) GetRefreshToken() string { @@ -1503,7 +1574,7 @@ type ExchangeClientTokenRequest struct { func (x *ExchangeClientTokenRequest) Reset() { *x = ExchangeClientTokenRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1515,7 +1586,7 @@ func (x *ExchangeClientTokenRequest) String() string { func (*ExchangeClientTokenRequest) ProtoMessage() {} func (x *ExchangeClientTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1528,7 +1599,7 @@ func (x *ExchangeClientTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExchangeClientTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeClientTokenRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{25} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{26} } func (x *ExchangeClientTokenRequest) GetClientId() string { @@ -1593,7 +1664,7 @@ type ExchangeClientTokenResponse struct { func (x *ExchangeClientTokenResponse) Reset() { *x = ExchangeClientTokenResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1605,7 +1676,7 @@ func (x *ExchangeClientTokenResponse) String() string { func (*ExchangeClientTokenResponse) ProtoMessage() {} func (x *ExchangeClientTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1618,7 +1689,7 @@ func (x *ExchangeClientTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExchangeClientTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeClientTokenResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{26} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{27} } func (x *ExchangeClientTokenResponse) GetAccessToken() string { @@ -1721,9 +1792,12 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\x1aSwitchOrganizationResponse\x12!\n" + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12\x1d\n" + "\n" + - "expires_in\x18\x02 \x01(\x03R\texpiresIn\"=\n" + + "expires_in\x18\x02 \x01(\x03R\texpiresIn\"a\n" + "\rLogoutRequest\x12,\n" + - "\rrefresh_token\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\frefreshToken\"+\n" + + "\rrefresh_token\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\frefreshToken\x12\"\n" + + "\rid_token_hint\x18\x02 \x01(\tR\vidTokenHint\"8\n" + + "\x0eLogoutResponse\x12&\n" + + "\x0fend_session_url\x18\x01 \x01(\tR\rendSessionUrl\"+\n" + "\fJWKSResponse\x12\x1b\n" + "\tkeys_json\x18\x01 \x01(\tR\bkeysJson\"d\n" + "\x11BeginOAuthRequest\x12#\n" + @@ -1765,7 +1839,7 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12#\n" + "\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\x12\x1d\n" + "\n" + - "expires_in\x18\x03 \x01(\x03R\texpiresIn2\xa5\x12\n" + + "expires_in\x18\x03 \x01(\x03R\texpiresIn2\xaf\x12\n" + "\vAuthService\x12\x90\x01\n" + "\n" + "BeginOAuth\x12#.saas.accounts.v1.BeginOAuthRequest\x1a$.saas.accounts.v1.BeginOAuthResponse\"7\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x03X\x04`\x01\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/v1/auth/oauth/begin\x12\xc9\x01\n" + @@ -1783,8 +1857,8 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\x16saas.mfa.webauthn_used\x10\x02@\x01H\x02P\x04X\x04`\x01h\x01\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/v1/auth/mfa/webauthn/complete\x12\x92\x01\n" + "\fRefreshToken\x12%.saas.accounts.v1.RefreshTokenRequest\x1a&.saas.accounts.v1.RefreshTokenResponse\"3\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x04`\x01\x82\xd3\xe4\x93\x02\x15:\x01*\"\x10/v1/auth/refresh\x12\xd1\x01\n" + "\x12SwitchOrganization\x12+.saas.accounts.v1.SwitchOrganizationRequest\x1a,.saas.accounts.v1.SwitchOrganizationResponse\"`\xc2\xf3\x185\b\x02\x10\x010\x01:#\n" + - "\x1fsaas.auth.organization_switched\x10\x02@\x01H\x05P\x03X\x04`\x01\x82\xd3\xe4\x93\x02!:\x01*\"\x1c/v1/auth/switch-organization\x12u\n" + - "\x06Logout\x12\x1f.saas.accounts.v1.LogoutRequest\x1a\x16.google.protobuf.Empty\"2\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x03`\x01\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/v1/auth/logout\x12\x81\x01\n" + + "\x1fsaas.auth.organization_switched\x10\x02@\x01H\x05P\x03X\x04`\x01\x82\xd3\xe4\x93\x02!:\x01*\"\x1c/v1/auth/switch-organization\x12\x7f\n" + + "\x06Logout\x12\x1f.saas.accounts.v1.LogoutRequest\x1a .saas.accounts.v1.LogoutResponse\"2\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x03`\x01\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/v1/auth/logout\x12\x81\x01\n" + "\aGetJWKS\x12\x16.google.protobuf.Empty\x1a\x1e.saas.accounts.v1.JWKSResponse\">\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x01P\x01X\x01`\x01\x82\xd3\xe4\x93\x02 \x12\x1e/v1/auth/.well-known/jwks.json\x12\xc8\x01\n" + "\x1bValidateClientAuthorization\x124.saas.accounts.v1.ValidateClientAuthorizationRequest\x1a5.saas.accounts.v1.ValidateClientAuthorizationResponse\"<\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x03X\x03`\x01\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/v1/auth/clients/validate\x12\xe9\x01\n" + "\x1cIssueClientAuthorizationCode\x125.saas.accounts.v1.IssueClientAuthorizationCodeRequest\x1a6.saas.accounts.v1.IssueClientAuthorizationCodeResponse\"Z\xc2\xf3\x181\b\x02\x10\x010\x01:\x1f\n" + @@ -1805,7 +1879,7 @@ func file_saas_accounts_v1_authentication_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_authentication_proto_rawDescData } -var file_saas_accounts_v1_authentication_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_saas_accounts_v1_authentication_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_saas_accounts_v1_authentication_proto_goTypes = []any{ (*OAuthCodeAuthentication)(nil), // 0: saas.accounts.v1.OAuthCodeAuthentication (*FixtureAuthentication)(nil), // 1: saas.accounts.v1.FixtureAuthentication @@ -1822,34 +1896,35 @@ var file_saas_accounts_v1_authentication_proto_goTypes = []any{ (*SwitchOrganizationRequest)(nil), // 12: saas.accounts.v1.SwitchOrganizationRequest (*SwitchOrganizationResponse)(nil), // 13: saas.accounts.v1.SwitchOrganizationResponse (*LogoutRequest)(nil), // 14: saas.accounts.v1.LogoutRequest - (*JWKSResponse)(nil), // 15: saas.accounts.v1.JWKSResponse - (*BeginOAuthRequest)(nil), // 16: saas.accounts.v1.BeginOAuthRequest - (*BeginOAuthResponse)(nil), // 17: saas.accounts.v1.BeginOAuthResponse - (*ClientAuthorizationRequest)(nil), // 18: saas.accounts.v1.ClientAuthorizationRequest - (*ValidateClientAuthorizationRequest)(nil), // 19: saas.accounts.v1.ValidateClientAuthorizationRequest - (*ValidateClientAuthorizationResponse)(nil), // 20: saas.accounts.v1.ValidateClientAuthorizationResponse - (*IssueClientAuthorizationCodeRequest)(nil), // 21: saas.accounts.v1.IssueClientAuthorizationCodeRequest - (*IssueClientAuthorizationCodeResponse)(nil), // 22: saas.accounts.v1.IssueClientAuthorizationCodeResponse - (*AuthorizationCodeGrant)(nil), // 23: saas.accounts.v1.AuthorizationCodeGrant - (*ClientRefreshTokenGrant)(nil), // 24: saas.accounts.v1.ClientRefreshTokenGrant - (*ExchangeClientTokenRequest)(nil), // 25: saas.accounts.v1.ExchangeClientTokenRequest - (*ExchangeClientTokenResponse)(nil), // 26: saas.accounts.v1.ExchangeClientTokenResponse - nil, // 27: saas.accounts.v1.AuthenticateRequest.ProfileEntry - (*User)(nil), // 28: saas.accounts.v1.User - (*emptypb.Empty)(nil), // 29: google.protobuf.Empty + (*LogoutResponse)(nil), // 15: saas.accounts.v1.LogoutResponse + (*JWKSResponse)(nil), // 16: saas.accounts.v1.JWKSResponse + (*BeginOAuthRequest)(nil), // 17: saas.accounts.v1.BeginOAuthRequest + (*BeginOAuthResponse)(nil), // 18: saas.accounts.v1.BeginOAuthResponse + (*ClientAuthorizationRequest)(nil), // 19: saas.accounts.v1.ClientAuthorizationRequest + (*ValidateClientAuthorizationRequest)(nil), // 20: saas.accounts.v1.ValidateClientAuthorizationRequest + (*ValidateClientAuthorizationResponse)(nil), // 21: saas.accounts.v1.ValidateClientAuthorizationResponse + (*IssueClientAuthorizationCodeRequest)(nil), // 22: saas.accounts.v1.IssueClientAuthorizationCodeRequest + (*IssueClientAuthorizationCodeResponse)(nil), // 23: saas.accounts.v1.IssueClientAuthorizationCodeResponse + (*AuthorizationCodeGrant)(nil), // 24: saas.accounts.v1.AuthorizationCodeGrant + (*ClientRefreshTokenGrant)(nil), // 25: saas.accounts.v1.ClientRefreshTokenGrant + (*ExchangeClientTokenRequest)(nil), // 26: saas.accounts.v1.ExchangeClientTokenRequest + (*ExchangeClientTokenResponse)(nil), // 27: saas.accounts.v1.ExchangeClientTokenResponse + nil, // 28: saas.accounts.v1.AuthenticateRequest.ProfileEntry + (*User)(nil), // 29: saas.accounts.v1.User + (*emptypb.Empty)(nil), // 30: google.protobuf.Empty } var file_saas_accounts_v1_authentication_proto_depIdxs = []int32{ - 27, // 0: saas.accounts.v1.AuthenticateRequest.profile:type_name -> saas.accounts.v1.AuthenticateRequest.ProfileEntry + 28, // 0: saas.accounts.v1.AuthenticateRequest.profile:type_name -> saas.accounts.v1.AuthenticateRequest.ProfileEntry 0, // 1: saas.accounts.v1.AuthenticateRequest.oauth_code:type_name -> saas.accounts.v1.OAuthCodeAuthentication 1, // 2: saas.accounts.v1.AuthenticateRequest.fixture:type_name -> saas.accounts.v1.FixtureAuthentication 2, // 3: saas.accounts.v1.AuthenticateRequest.header_jwt:type_name -> saas.accounts.v1.HeaderJWTAuthentication - 28, // 4: saas.accounts.v1.AuthenticateResponse.user:type_name -> saas.accounts.v1.User - 28, // 5: saas.accounts.v1.CompleteMFAChallengeResponse.user:type_name -> saas.accounts.v1.User - 18, // 6: saas.accounts.v1.ValidateClientAuthorizationRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest - 18, // 7: saas.accounts.v1.IssueClientAuthorizationCodeRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest - 23, // 8: saas.accounts.v1.ExchangeClientTokenRequest.authorization_code:type_name -> saas.accounts.v1.AuthorizationCodeGrant - 24, // 9: saas.accounts.v1.ExchangeClientTokenRequest.refresh_token:type_name -> saas.accounts.v1.ClientRefreshTokenGrant - 16, // 10: saas.accounts.v1.AuthService.BeginOAuth:input_type -> saas.accounts.v1.BeginOAuthRequest + 29, // 4: saas.accounts.v1.AuthenticateResponse.user:type_name -> saas.accounts.v1.User + 29, // 5: saas.accounts.v1.CompleteMFAChallengeResponse.user:type_name -> saas.accounts.v1.User + 19, // 6: saas.accounts.v1.ValidateClientAuthorizationRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest + 19, // 7: saas.accounts.v1.IssueClientAuthorizationCodeRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest + 24, // 8: saas.accounts.v1.ExchangeClientTokenRequest.authorization_code:type_name -> saas.accounts.v1.AuthorizationCodeGrant + 25, // 9: saas.accounts.v1.ExchangeClientTokenRequest.refresh_token:type_name -> saas.accounts.v1.ClientRefreshTokenGrant + 17, // 10: saas.accounts.v1.AuthService.BeginOAuth:input_type -> saas.accounts.v1.BeginOAuthRequest 3, // 11: saas.accounts.v1.AuthService.Authenticate:input_type -> saas.accounts.v1.AuthenticateRequest 5, // 12: saas.accounts.v1.AuthService.CompleteMFAChallenge:input_type -> saas.accounts.v1.CompleteMFAChallengeRequest 7, // 13: saas.accounts.v1.AuthService.BeginWebAuthnMFAChallenge:input_type -> saas.accounts.v1.BeginWebAuthnMFAChallengeRequest @@ -1857,22 +1932,22 @@ var file_saas_accounts_v1_authentication_proto_depIdxs = []int32{ 10, // 15: saas.accounts.v1.AuthService.RefreshToken:input_type -> saas.accounts.v1.RefreshTokenRequest 12, // 16: saas.accounts.v1.AuthService.SwitchOrganization:input_type -> saas.accounts.v1.SwitchOrganizationRequest 14, // 17: saas.accounts.v1.AuthService.Logout:input_type -> saas.accounts.v1.LogoutRequest - 29, // 18: saas.accounts.v1.AuthService.GetJWKS:input_type -> google.protobuf.Empty - 19, // 19: saas.accounts.v1.AuthService.ValidateClientAuthorization:input_type -> saas.accounts.v1.ValidateClientAuthorizationRequest - 21, // 20: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:input_type -> saas.accounts.v1.IssueClientAuthorizationCodeRequest - 25, // 21: saas.accounts.v1.AuthService.ExchangeClientToken:input_type -> saas.accounts.v1.ExchangeClientTokenRequest - 17, // 22: saas.accounts.v1.AuthService.BeginOAuth:output_type -> saas.accounts.v1.BeginOAuthResponse + 30, // 18: saas.accounts.v1.AuthService.GetJWKS:input_type -> google.protobuf.Empty + 20, // 19: saas.accounts.v1.AuthService.ValidateClientAuthorization:input_type -> saas.accounts.v1.ValidateClientAuthorizationRequest + 22, // 20: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:input_type -> saas.accounts.v1.IssueClientAuthorizationCodeRequest + 26, // 21: saas.accounts.v1.AuthService.ExchangeClientToken:input_type -> saas.accounts.v1.ExchangeClientTokenRequest + 18, // 22: saas.accounts.v1.AuthService.BeginOAuth:output_type -> saas.accounts.v1.BeginOAuthResponse 4, // 23: saas.accounts.v1.AuthService.Authenticate:output_type -> saas.accounts.v1.AuthenticateResponse 6, // 24: saas.accounts.v1.AuthService.CompleteMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse 8, // 25: saas.accounts.v1.AuthService.BeginWebAuthnMFAChallenge:output_type -> saas.accounts.v1.BeginWebAuthnMFAChallengeResponse 6, // 26: saas.accounts.v1.AuthService.CompleteWebAuthnMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse 11, // 27: saas.accounts.v1.AuthService.RefreshToken:output_type -> saas.accounts.v1.RefreshTokenResponse 13, // 28: saas.accounts.v1.AuthService.SwitchOrganization:output_type -> saas.accounts.v1.SwitchOrganizationResponse - 29, // 29: saas.accounts.v1.AuthService.Logout:output_type -> google.protobuf.Empty - 15, // 30: saas.accounts.v1.AuthService.GetJWKS:output_type -> saas.accounts.v1.JWKSResponse - 20, // 31: saas.accounts.v1.AuthService.ValidateClientAuthorization:output_type -> saas.accounts.v1.ValidateClientAuthorizationResponse - 22, // 32: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:output_type -> saas.accounts.v1.IssueClientAuthorizationCodeResponse - 26, // 33: saas.accounts.v1.AuthService.ExchangeClientToken:output_type -> saas.accounts.v1.ExchangeClientTokenResponse + 15, // 29: saas.accounts.v1.AuthService.Logout:output_type -> saas.accounts.v1.LogoutResponse + 16, // 30: saas.accounts.v1.AuthService.GetJWKS:output_type -> saas.accounts.v1.JWKSResponse + 21, // 31: saas.accounts.v1.AuthService.ValidateClientAuthorization:output_type -> saas.accounts.v1.ValidateClientAuthorizationResponse + 23, // 32: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:output_type -> saas.accounts.v1.IssueClientAuthorizationCodeResponse + 27, // 33: saas.accounts.v1.AuthService.ExchangeClientToken:output_type -> saas.accounts.v1.ExchangeClientTokenResponse 22, // [22:34] is the sub-list for method output_type 10, // [10:22] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name @@ -1891,7 +1966,7 @@ func file_saas_accounts_v1_authentication_proto_init() { (*AuthenticateRequest_Fixture)(nil), (*AuthenticateRequest_HeaderJwt)(nil), } - file_saas_accounts_v1_authentication_proto_msgTypes[25].OneofWrappers = []any{ + file_saas_accounts_v1_authentication_proto_msgTypes[26].OneofWrappers = []any{ (*ExchangeClientTokenRequest_AuthorizationCode)(nil), (*ExchangeClientTokenRequest_RefreshToken)(nil), } @@ -1901,7 +1976,7 @@ func file_saas_accounts_v1_authentication_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_authentication_proto_rawDesc), len(file_saas_accounts_v1_authentication_proto_rawDesc)), NumEnums: 0, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 1, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go index 8166647be..c1fe4093f 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go @@ -59,7 +59,10 @@ type AuthServiceClient interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(ctx context.Context, in *SwitchOrganizationRequest, opts ...grpc.CallOption) (*SwitchOrganizationResponse, error) - Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) GetJWKS(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*JWKSResponse, error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -158,9 +161,9 @@ func (c *authServiceClient) SwitchOrganization(ctx context.Context, in *SwitchOr return out, nil } -func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { +func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(emptypb.Empty) + out := new(LogoutResponse) err := c.cc.Invoke(ctx, AuthService_Logout_FullMethodName, in, out, cOpts...) if err != nil { return nil, err @@ -232,7 +235,10 @@ type AuthServiceServer interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *SwitchOrganizationRequest) (*SwitchOrganizationResponse, error) - Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) GetJWKS(context.Context, *emptypb.Empty) (*JWKSResponse, error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -282,7 +288,7 @@ func (UnimplementedAuthServiceServer) RefreshToken(context.Context, *RefreshToke func (UnimplementedAuthServiceServer) SwitchOrganization(context.Context, *SwitchOrganizationRequest) (*SwitchOrganizationResponse, error) { return nil, status.Error(codes.Unimplemented, "method SwitchOrganization not implemented") } -func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) { +func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) { return nil, status.Error(codes.Unimplemented, "method Logout not implemented") } func (UnimplementedAuthServiceServer) GetJWKS(context.Context, *emptypb.Empty) (*JWKSResponse, error) { diff --git a/module/services/accounts/code/pkg/infra/postgres_sessions.go b/module/services/accounts/code/pkg/infra/postgres_sessions.go index 9f4cbbcc2..70b0354df 100644 --- a/module/services/accounts/code/pkg/infra/postgres_sessions.go +++ b/module/services/accounts/code/pkg/infra/postgres_sessions.go @@ -136,12 +136,31 @@ func (s *PostgresStore) RevokeSessionFamily(ctx context.Context, familyID string return err } -func (s *PostgresStore) RevokeAllUserSessions(ctx context.Context, userID string, reason string) error { +// RevokeAllUserSessions revokes every live session of one user and returns the +// ids it revoked. The ids are what lets a caller kill the access half as well: +// revoking the rows ends refreshing, but an already-minted access token stays +// valid until its natural TTL unless a per-session revocation marker is +// written, exactly as RevokeSession's caller does. Returning nothing was the +// shape that made a whole-user sign-out quietly weaker than a single-family +// one. +func (s *PostgresStore) RevokeAllUserSessions(ctx context.Context, userID string, reason string) ([]string, error) { q := s.getQueryExecutor(ctx) - _, err := q.Exec(ctx, - `UPDATE sessions SET revoked_at = NOW(), revoked_reason = $2 WHERE user_id = $1 AND revoked_at IS NULL`, + rows, err := q.Query(ctx, + `UPDATE sessions SET revoked_at = NOW(), revoked_reason = $2 WHERE user_id = $1 AND revoked_at IS NULL RETURNING id`, userID, reason) - return err + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() } func (s *PostgresStore) UpdateSessionActivity(ctx context.Context, sessionID string) error { diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 23fc66c9e..5f93daabe 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -667,6 +667,29 @@ func doWork(ctx context.Context) (clean Clean, err error) { service.SetTokenValidator(v) service.SetCodeExchanger(ex) + // Signing out of this host does not sign the person out of the identity + // provider: the provider's session cookie is in their browser, and the + // next sign-in through it is silent. RP-initiated logout and + // back-channel logout are the two halves of closing that, and a provider + // offers them independently, so each is wired on its own evidence. + signOut := configuredProviderSignOut(ctx, authProvider) + if verifier, ok := v.(business.LogoutTokenVerifier); ok { + signOut.LogoutTokens = verifier + } + service.SetProviderSignOut(signOut) + if service.ProviderSignOutConfigured() { + adapters.RegisterHTTPRoute(adapters.BackchannelLogoutPath, adapters.NewBackchannelLogoutHandler(service)) + w.Info("provider back-channel logout enabled", wool.Field("path", adapters.BackchannelLogoutPath)) + } else { + // An unauthenticated route whose only honest answer is a refusal is + // worse than no route: it answers, so it looks implemented. + w.Info("the configured identity provider cannot verify a back-channel logout token; no such endpoint is mounted") + } + if signOut.EndSessionEndpoint == "" { + w.Info("the configured identity provider publishes no end_session_endpoint; a sign-out ends the host session only", + wool.Field("provider", authProvider)) + } + // Per-org identity provider registry (issue #107). Orgs with an active // row in org_identity_providers resolve to their own stack; every other // org falls back to this global default (v, ex). Stacks build lazily on @@ -2364,6 +2387,49 @@ func discoverOIDCValidator(provider string, defaultAudienceToClientID bool) (val return v, tokenURL, clientID, clientSecret, nil } +// configuredProviderSignOut resolves what the identity provider offers for +// ending its own session. The endpoint comes from an explicit +// IDENTITY_END_SESSION_URL pin, or from the provider's discovery document when +// the deployment configured an issuer — the same two sources, in the same +// order, as the JWKS and token endpoints. +// +// A discovery failure here is a warning and not a boot refusal, deliberately: +// by the time this runs the login path has already discovered the same document +// successfully (buildProviderStack would have failed the boot otherwise), so a +// failure is transient, and refusing to start a host whose sign-in works because +// its sign-out could not be enriched trades a working deployment for a +// cosmetic one. The consequence is stated at boot instead: no end-session URL +// is returned, and a sign-out ends the host session only. +// +// A provider configured from a preset rather than from an issuer (auth0, +// google) reaches this with no IDENTITY_ISSUER and needs the explicit pin; +// Google publishes no end_session_endpoint at all. +func configuredProviderSignOut(ctx context.Context, provider string) business.ProviderSignOut { + signOut := business.ProviderSignOut{ + ClientID: identityEnv("IDENTITY_CLIENT_ID"), + PostLogoutRedirectURI: identityEnv("IDENTITY_POST_LOGOUT_REDIRECT_URI"), + } + if pinned := identityEnv("IDENTITY_END_SESSION_URL"); hasConfiguredValue(pinned) { + signOut.EndSessionEndpoint = pinned + return signOut + } + issuer := identityEnv("IDENTITY_ISSUER") + if !hasConfiguredValue(issuer) { + return signOut + } + discoverCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + discovered, err := oidc.Discover(discoverCtx, issuer, nil) + if err != nil { + wool.Get(ctx).In("configuredProviderSignOut").Warn( + "could not read the provider's end_session_endpoint; a sign-out ends the host session only", + wool.Field("provider", provider), wool.ErrField(err)) + return signOut + } + signOut.EndSessionEndpoint = discovered.EndSessionEndpoint + return signOut +} + func identityEnvOrDefault(key, fallback string) string { if value := identityEnv(key); hasConfiguredValue(value) { return value diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index d4274ef92..49d116537 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -1880,7 +1880,7 @@ "full_service": "saas.accounts.v1.AuthService", "method": "Logout", "input_type": "saas.accounts.v1.LogoutRequest", - "output_type": "google.protobuf.Empty", + "output_type": "saas.accounts.v1.LogoutResponse", "client_streaming": false, "server_streaming": false, "policy": { diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 115c508ec..8466556b8 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -870,13 +870,13 @@ }, "/v1/auth/logout": { "post": { + "summary": "Logout ends the host session and, when the identity provider publishes an\n`end_session_endpoint`, returns the RP-initiated logout URL for the client\nto complete. See module/AUTHENTICATION.md \"Signing out\".", "operationId": "AuthService_Logout", "responses": { "200": { "description": "A successful response.", "schema": { - "type": "object", - "properties": {} + "$ref": "#/definitions/v1LogoutResponse" } }, "default": { @@ -10600,9 +10600,23 @@ "properties": { "refreshToken": { "type": "string" + }, + "idTokenHint": { + "type": "string", + "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending." } } }, + "v1LogoutResponse": { + "type": "object", + "properties": { + "endSessionUrl": { + "type": "string", + "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to after the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure." + } + }, + "description": "LogoutResponse carries what the host cannot do on the person's behalf. The\nhost ends its own session unconditionally; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make." + }, "v1MFADevice": { "type": "object", "properties": { diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 87edf3db3..835fe99d8 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -2904,7 +2904,7 @@ "full_service": "saas.accounts.v1.AuthService", "method": "Logout", "input_type": "saas.accounts.v1.LogoutRequest", - "output_type": "google.protobuf.Empty", + "output_type": "saas.accounts.v1.LogoutResponse", "client_streaming": false, "server_streaming": false, "deprecated": false, diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 7450d6bd1..3eb6431ea 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -3684,12 +3684,26 @@ }, "v1LogoutRequest": { "properties": { + "idTokenHint": { + "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending.", + "type": "string" + }, "refreshToken": { "type": "string" } }, "type": "object" }, + "v1LogoutResponse": { + "description": "LogoutResponse carries what the host cannot do on the person's behalf. The\nhost ends its own session unconditionally; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make.", + "properties": { + "endSessionUrl": { + "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to after the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure.", + "type": "string" + } + }, + "type": "object" + }, "v1MFADevice": { "properties": { "createdAt": { @@ -7045,8 +7059,7 @@ "200": { "description": "A successful response.", "schema": { - "properties": {}, - "type": "object" + "$ref": "#/definitions/v1LogoutResponse" } }, "default": { @@ -7056,6 +7069,7 @@ } } }, + "summary": "Logout ends the host session and, when the identity provider publishes an\n`end_session_endpoint`, returns the RP-initiated logout URL for the client\nto complete. See module/AUTHENTICATION.md \"Signing out\".", "tags": [ "AuthService" ] diff --git a/module/services/accounts/proto/saas/accounts/v1/authentication.proto b/module/services/accounts/proto/saas/accounts/v1/authentication.proto index 182a5e694..a9ce38080 100644 --- a/module/services/accounts/proto/saas/accounts/v1/authentication.proto +++ b/module/services/accounts/proto/saas/accounts/v1/authentication.proto @@ -170,6 +170,31 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; + + // id_token_hint is the provider's id_token for the session being ended, as + // the client still holds it. It is optional and client-supplied on purpose: + // the host never retains a provider id_token — it has no other use for one, + // and storing a provider credential to hand back later is a liability, not a + // feature. When it is absent the end-session URL carries `client_id` instead, + // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + // where no hint is available. The provider may then ask the person to confirm + // which session they are ending. + string id_token_hint = 2; +} + +// LogoutResponse carries what the host cannot do on the person's behalf. The +// host ends its own session unconditionally; reaching the identity provider +// requires the person's own user agent, because the provider's session cookie +// is in their browser and not in any request accounts can make. +message LogoutResponse { + // end_session_url is the provider's `end_session_endpoint` with the + // RP-initiated logout parameters already applied, for the client to send the + // person's browser to after the host session has ended. Empty when this + // deployment's provider publishes no `end_session_endpoint`, or when the + // configured provider has no OIDC sign-out at all (the development fixture): + // an empty value is the whole answer, and a client must treat it as "nothing + // further to do" rather than as a failure. + string end_session_url = 1; } message JWKSResponse { @@ -458,7 +483,10 @@ service AuthService { }; } - rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { + // Logout ends the host session and, when the identity provider publishes an + // `end_session_endpoint`, returns the RP-initiated logout URL for the client + // to complete. See module/AUTHENTICATION.md "Signing out". + rpc Logout(LogoutRequest) returns (LogoutResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE diff --git a/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml b/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml index 94e57a193..acb2bae61 100644 --- a/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml +++ b/module/services/auth-gateway/routing/rest/saas-starter/accounts/non-protobuf-extensions.rest.codefly.yaml @@ -118,6 +118,26 @@ routes: exposed: true protected: false rate-limit-class: RATE_LIMIT_CLASS_AUTHENTICATION + # The OpenID Connect back-channel logout receiver (accounts + # pkg/adapters/backchannel_logout_http.go). Not a protobuf RPC because + # Back-Channel Logout 1.0 §2.5 fixes the shape: a form-encoded + # `logout_token` parameter, a bare 200, and a 400 with no cause — none of + # which grpc-gateway produces, and the caller is the provider's own client + # rather than any code of ours. + # + # Unprotected because the caller is the identity provider's back channel: it + # holds no session, no bearer and no API key here, and the signed logout + # token IS the credential, verified against the provider's JWKS with the + # same issuer and audience binding that admits a sign-in. accounts mounts + # the route only when a provider that can verify such a token is configured, + # so on a deployment without one the gateway route leads nowhere and the + # endpoint does not exist. + - path: /v1/auth/backchannel-logout + method: POST + extension: + exposed: true + protected: false + rate-limit-class: RATE_LIMIT_CLASS_AUTHENTICATION # The code is issued from the signed-in person's own host session, so this # one is protected: the full ext_authz bearer path runs and accounts reads # the forwarded identity beside the gateway credential. diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index c71813471..3eb655220 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e + contract-digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 services: - AccessibleScopeService - AuditService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts index 15faed97c..b2a7eff4e 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/authentication.proto. */ export const file_saas_accounts_v1_authentication: GenFile = /*@__PURE__*/ - fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiLwoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMqUSCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SdQoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjLC8xgUCAEQATABOgIQAUABSAJQBFgDYAGC0+STAhQ6ASoiDy92MS9hdXRoL2xvZ291dBKBAQoHR2V0SldLUxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoeLnNhYXMuYWNjb3VudHMudjEuSldLU1Jlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAFQAVgBYAGC0+STAiASHi92MS9hdXRoLy53ZWxsLWtub3duL2p3a3MuanNvbhLIAQobVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uEjQuc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXNwb25zZSI8wvMYFAgBEAEwAToCEAFAAUgCUANYA2ABgtPkkwIeOgEqIhkvdjEvYXV0aC9jbGllbnRzL3ZhbGlkYXRlEukBChxJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlEjUuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVxdWVzdBo2LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlc3BvbnNlIlrC8xgxCAIQATABOh8KG3NhYXMuYXV0aC5jbGllbnRfYXV0aG9yaXplZBACQAFIBVADWARgAYLT5JMCHzoBKiIaL3YxL2F1dGgvY2xpZW50cy9hdXRob3JpemUStgEKE0V4Y2hhbmdlQ2xpZW50VG9rZW4SLC5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVzcG9uc2UiQsLzGCUIARABMAE6EwoPc2Fhcy5hdXRoLmxvZ2luEAJAAUgCUARYBGABgtPkkwITOgEqIg4vdjEvYXV0aC90b2tlbmIGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiRgoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABEhUKDWlkX3Rva2VuX2hpbnQYAiABKAkiKQoOTG9nb3V0UmVzcG9uc2USFwoPZW5kX3Nlc3Npb25fdXJsGAEgASgJIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMq8SCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SfwoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GiAuc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXNwb25zZSIywvMYFAgBEAEwAToCEAFAAUgCUARYA2ABgtPkkwIUOgEqIg8vdjEvYXV0aC9sb2dvdXQSgQEKB0dldEpXS1MSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHi5zYWFzLmFjY291bnRzLnYxLkpXS1NSZXNwb25zZSI+wvMYFAgBEAEwAToCEAFAAUgBUAFYAWABgtPkkwIgEh4vdjEvYXV0aC8ud2VsbC1rbm93bi9qd2tzLmpzb24SyAEKG1ZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2UiPMLzGBQIARABMAE6AhABQAFIAlADWANgAYLT5JMCHjoBKiIZL3YxL2F1dGgvY2xpZW50cy92YWxpZGF0ZRLpAQocSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZRI1LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZSJawvMYMQgCEAEwATofChtzYWFzLmF1dGguY2xpZW50X2F1dGhvcml6ZWQQAkABSAVQA1gEYAGC0+STAh86ASoiGi92MS9hdXRoL2NsaWVudHMvYXV0aG9yaXplErYBChNFeGNoYW5nZUNsaWVudFRva2VuEiwuc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBotLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlIkLC8xglCAEQATABOhMKD3NhYXMuYXV0aC5sb2dpbhACQAFIAlAEWARgAYLT5JMCEzoBKiIOL3YxL2F1dGgvdG9rZW5iBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.OAuthCodeAuthentication @@ -449,6 +449,20 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * @generated from field: string refresh_token = 1; */ refreshToken: string; + + /** + * id_token_hint is the provider's id_token for the session being ended, as + * the client still holds it. It is optional and client-supplied on purpose: + * the host never retains a provider id_token — it has no other use for one, + * and storing a provider credential to hand back later is a liability, not a + * feature. When it is absent the end-session URL carries `client_id` instead, + * which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + * where no hint is available. The provider may then ask the person to confirm + * which session they are ending. + * + * @generated from field: string id_token_hint = 2; + */ + idTokenHint: string; }; /** @@ -458,6 +472,36 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_authentication, 14); +/** + * LogoutResponse carries what the host cannot do on the person's behalf. The + * host ends its own session unconditionally; reaching the identity provider + * requires the person's own user agent, because the provider's session cookie + * is in their browser and not in any request accounts can make. + * + * @generated from message saas.accounts.v1.LogoutResponse + */ +export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { + /** + * end_session_url is the provider's `end_session_endpoint` with the + * RP-initiated logout parameters already applied, for the client to send the + * person's browser to after the host session has ended. Empty when this + * deployment's provider publishes no `end_session_endpoint`, or when the + * configured provider has no OIDC sign-out at all (the development fixture): + * an empty value is the whole answer, and a client must treat it as "nothing + * further to do" rather than as a failure. + * + * @generated from field: string end_session_url = 1; + */ + endSessionUrl: string; +}; + +/** + * Describes the message saas.accounts.v1.LogoutResponse. + * Use `create(LogoutResponseSchema)` to create a new message. + */ +export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 15); + /** * @generated from message saas.accounts.v1.JWKSResponse */ @@ -473,7 +517,7 @@ export type JWKSResponse = Message<"saas.accounts.v1.JWKSResponse"> & { * Use `create(JWKSResponseSchema)` to create a new message. */ export const JWKSResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 15); + messageDesc(file_saas_accounts_v1_authentication, 16); /** * BeginOAuthRequest is the FE's first hop on the authorization-code @@ -501,7 +545,7 @@ export type BeginOAuthRequest = Message<"saas.accounts.v1.BeginOAuthRequest"> & * Use `create(BeginOAuthRequestSchema)` to create a new message. */ export const BeginOAuthRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 16); + messageDesc(file_saas_accounts_v1_authentication, 17); /** * @generated from message saas.accounts.v1.BeginOAuthResponse @@ -521,7 +565,7 @@ export type BeginOAuthResponse = Message<"saas.accounts.v1.BeginOAuthResponse"> * Use `create(BeginOAuthResponseSchema)` to create a new message. */ export const BeginOAuthResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 17); + messageDesc(file_saas_accounts_v1_authentication, 18); /** * ClientAuthorizationRequest is the authorization request a client encodes into @@ -562,7 +606,7 @@ export type ClientAuthorizationRequest = Message<"saas.accounts.v1.ClientAuthori * Use `create(ClientAuthorizationRequestSchema)` to create a new message. */ export const ClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 18); + messageDesc(file_saas_accounts_v1_authentication, 19); /** * @generated from message saas.accounts.v1.ValidateClientAuthorizationRequest @@ -579,7 +623,7 @@ export type ValidateClientAuthorizationRequest = Message<"saas.accounts.v1.Valid * Use `create(ValidateClientAuthorizationRequestSchema)` to create a new message. */ export const ValidateClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 19); + messageDesc(file_saas_accounts_v1_authentication, 20); /** * ValidateClientAuthorizationResponse carries only what the login page needs to @@ -600,7 +644,7 @@ export type ValidateClientAuthorizationResponse = Message<"saas.accounts.v1.Vali * Use `create(ValidateClientAuthorizationResponseSchema)` to create a new message. */ export const ValidateClientAuthorizationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 20); + messageDesc(file_saas_accounts_v1_authentication, 21); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeRequest @@ -617,7 +661,7 @@ export type IssueClientAuthorizationCodeRequest = Message<"saas.accounts.v1.Issu * Use `create(IssueClientAuthorizationCodeRequestSchema)` to create a new message. */ export const IssueClientAuthorizationCodeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 21); + messageDesc(file_saas_accounts_v1_authentication, 22); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeResponse @@ -642,7 +686,7 @@ export type IssueClientAuthorizationCodeResponse = Message<"saas.accounts.v1.Iss * Use `create(IssueClientAuthorizationCodeResponseSchema)` to create a new message. */ export const IssueClientAuthorizationCodeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 22); + messageDesc(file_saas_accounts_v1_authentication, 23); /** * AuthorizationCodeGrant redeems a code issued by IssueClientAuthorizationCode. @@ -673,7 +717,7 @@ export type AuthorizationCodeGrant = Message<"saas.accounts.v1.AuthorizationCode * Use `create(AuthorizationCodeGrantSchema)` to create a new message. */ export const AuthorizationCodeGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 23); + messageDesc(file_saas_accounts_v1_authentication, 24); /** * ClientRefreshTokenGrant rotates a client's own refresh token. The presented @@ -694,7 +738,7 @@ export type ClientRefreshTokenGrant = Message<"saas.accounts.v1.ClientRefreshTok * Use `create(ClientRefreshTokenGrantSchema)` to create a new message. */ export const ClientRefreshTokenGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 24); + messageDesc(file_saas_accounts_v1_authentication, 25); /** * @generated from message saas.accounts.v1.ExchangeClientTokenRequest @@ -728,7 +772,7 @@ export type ExchangeClientTokenRequest = Message<"saas.accounts.v1.ExchangeClien * Use `create(ExchangeClientTokenRequestSchema)` to create a new message. */ export const ExchangeClientTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 25); + messageDesc(file_saas_accounts_v1_authentication, 26); /** * ExchangeClientTokenResponse returns both halves in the body. A public client @@ -759,7 +803,7 @@ export type ExchangeClientTokenResponse = Message<"saas.accounts.v1.ExchangeClie * Use `create(ExchangeClientTokenResponseSchema)` to create a new message. */ export const ExchangeClientTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 26); + messageDesc(file_saas_accounts_v1_authentication, 27); /** * AuthService — JWT token issuance and session management @@ -839,12 +883,16 @@ export const AuthService: GenService<{ output: typeof SwitchOrganizationResponseSchema; }, /** + * Logout ends the host session and, when the identity provider publishes an + * `end_session_endpoint`, returns the RP-initiated logout URL for the client + * to complete. See module/AUTHENTICATION.md "Signing out". + * * @generated from rpc saas.accounts.v1.AuthService.Logout */ logout: { methodKind: "unary"; input: typeof LogoutRequestSchema; - output: typeof EmptySchema; + output: typeof LogoutResponseSchema; }, /** * @generated from rpc saas.accounts.v1.AuthService.GetJWKS diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts index 7e88d4d20..ef7204614 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/authentication.proto. */ export const file_saas_accounts_v1_authentication: GenFile = /*@__PURE__*/ - fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiLwoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMqUSCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SdQoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjLC8xgUCAEQATABOgIQAUABSAJQBFgDYAGC0+STAhQ6ASoiDy92MS9hdXRoL2xvZ291dBKBAQoHR2V0SldLUxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoeLnNhYXMuYWNjb3VudHMudjEuSldLU1Jlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAFQAVgBYAGC0+STAiASHi92MS9hdXRoLy53ZWxsLWtub3duL2p3a3MuanNvbhLIAQobVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uEjQuc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5WYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXNwb25zZSI8wvMYFAgBEAEwAToCEAFAAUgCUANYA2ABgtPkkwIeOgEqIhkvdjEvYXV0aC9jbGllbnRzL3ZhbGlkYXRlEukBChxJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlEjUuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVxdWVzdBo2LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlc3BvbnNlIlrC8xgxCAIQATABOh8KG3NhYXMuYXV0aC5jbGllbnRfYXV0aG9yaXplZBACQAFIBVADWARgAYLT5JMCHzoBKiIaL3YxL2F1dGgvY2xpZW50cy9hdXRob3JpemUStgEKE0V4Y2hhbmdlQ2xpZW50VG9rZW4SLC5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVzcG9uc2UiQsLzGCUIARABMAE6EwoPc2Fhcy5hdXRoLmxvZ2luEAJAAUgCUARYBGABgtPkkwITOgEqIg4vdjEvYXV0aC90b2tlbkK7AQoUY29tLnNhYXMuYWNjb3VudHMudjFCE0F1dGhlbnRpY2F0aW9uUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiRgoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABEhUKDWlkX3Rva2VuX2hpbnQYAiABKAkiKQoOTG9nb3V0UmVzcG9uc2USFwoPZW5kX3Nlc3Npb25fdXJsGAEgASgJIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMq8SCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SfwoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GiAuc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXNwb25zZSIywvMYFAgBEAEwAToCEAFAAUgCUARYA2ABgtPkkwIUOgEqIg8vdjEvYXV0aC9sb2dvdXQSgQEKB0dldEpXS1MSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHi5zYWFzLmFjY291bnRzLnYxLkpXS1NSZXNwb25zZSI+wvMYFAgBEAEwAToCEAFAAUgBUAFYAWABgtPkkwIgEh4vdjEvYXV0aC8ud2VsbC1rbm93bi9qd2tzLmpzb24SyAEKG1ZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2UiPMLzGBQIARABMAE6AhABQAFIAlADWANgAYLT5JMCHjoBKiIZL3YxL2F1dGgvY2xpZW50cy92YWxpZGF0ZRLpAQocSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZRI1LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZSJawvMYMQgCEAEwATofChtzYWFzLmF1dGguY2xpZW50X2F1dGhvcml6ZWQQAkABSAVQA1gEYAGC0+STAh86ASoiGi92MS9hdXRoL2NsaWVudHMvYXV0aG9yaXplErYBChNFeGNoYW5nZUNsaWVudFRva2VuEiwuc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBotLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlIkLC8xglCAEQATABOhMKD3NhYXMuYXV0aC5sb2dpbhACQAFIAlAEWARgAYLT5JMCEzoBKiIOL3YxL2F1dGgvdG9rZW5CuwEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhNBdXRoZW50aWNhdGlvblByb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.OAuthCodeAuthentication @@ -449,6 +449,20 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * @generated from field: string refresh_token = 1; */ refreshToken: string; + + /** + * id_token_hint is the provider's id_token for the session being ended, as + * the client still holds it. It is optional and client-supplied on purpose: + * the host never retains a provider id_token — it has no other use for one, + * and storing a provider credential to hand back later is a liability, not a + * feature. When it is absent the end-session URL carries `client_id` instead, + * which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + * where no hint is available. The provider may then ask the person to confirm + * which session they are ending. + * + * @generated from field: string id_token_hint = 2; + */ + idTokenHint: string; }; /** @@ -458,6 +472,36 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_authentication, 14); +/** + * LogoutResponse carries what the host cannot do on the person's behalf. The + * host ends its own session unconditionally; reaching the identity provider + * requires the person's own user agent, because the provider's session cookie + * is in their browser and not in any request accounts can make. + * + * @generated from message saas.accounts.v1.LogoutResponse + */ +export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { + /** + * end_session_url is the provider's `end_session_endpoint` with the + * RP-initiated logout parameters already applied, for the client to send the + * person's browser to after the host session has ended. Empty when this + * deployment's provider publishes no `end_session_endpoint`, or when the + * configured provider has no OIDC sign-out at all (the development fixture): + * an empty value is the whole answer, and a client must treat it as "nothing + * further to do" rather than as a failure. + * + * @generated from field: string end_session_url = 1; + */ + endSessionUrl: string; +}; + +/** + * Describes the message saas.accounts.v1.LogoutResponse. + * Use `create(LogoutResponseSchema)` to create a new message. + */ +export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 15); + /** * @generated from message saas.accounts.v1.JWKSResponse */ @@ -473,7 +517,7 @@ export type JWKSResponse = Message<"saas.accounts.v1.JWKSResponse"> & { * Use `create(JWKSResponseSchema)` to create a new message. */ export const JWKSResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 15); + messageDesc(file_saas_accounts_v1_authentication, 16); /** * BeginOAuthRequest is the FE's first hop on the authorization-code @@ -501,7 +545,7 @@ export type BeginOAuthRequest = Message<"saas.accounts.v1.BeginOAuthRequest"> & * Use `create(BeginOAuthRequestSchema)` to create a new message. */ export const BeginOAuthRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 16); + messageDesc(file_saas_accounts_v1_authentication, 17); /** * @generated from message saas.accounts.v1.BeginOAuthResponse @@ -521,7 +565,7 @@ export type BeginOAuthResponse = Message<"saas.accounts.v1.BeginOAuthResponse"> * Use `create(BeginOAuthResponseSchema)` to create a new message. */ export const BeginOAuthResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 17); + messageDesc(file_saas_accounts_v1_authentication, 18); /** * ClientAuthorizationRequest is the authorization request a client encodes into @@ -562,7 +606,7 @@ export type ClientAuthorizationRequest = Message<"saas.accounts.v1.ClientAuthori * Use `create(ClientAuthorizationRequestSchema)` to create a new message. */ export const ClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 18); + messageDesc(file_saas_accounts_v1_authentication, 19); /** * @generated from message saas.accounts.v1.ValidateClientAuthorizationRequest @@ -579,7 +623,7 @@ export type ValidateClientAuthorizationRequest = Message<"saas.accounts.v1.Valid * Use `create(ValidateClientAuthorizationRequestSchema)` to create a new message. */ export const ValidateClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 19); + messageDesc(file_saas_accounts_v1_authentication, 20); /** * ValidateClientAuthorizationResponse carries only what the login page needs to @@ -600,7 +644,7 @@ export type ValidateClientAuthorizationResponse = Message<"saas.accounts.v1.Vali * Use `create(ValidateClientAuthorizationResponseSchema)` to create a new message. */ export const ValidateClientAuthorizationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 20); + messageDesc(file_saas_accounts_v1_authentication, 21); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeRequest @@ -617,7 +661,7 @@ export type IssueClientAuthorizationCodeRequest = Message<"saas.accounts.v1.Issu * Use `create(IssueClientAuthorizationCodeRequestSchema)` to create a new message. */ export const IssueClientAuthorizationCodeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 21); + messageDesc(file_saas_accounts_v1_authentication, 22); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeResponse @@ -642,7 +686,7 @@ export type IssueClientAuthorizationCodeResponse = Message<"saas.accounts.v1.Iss * Use `create(IssueClientAuthorizationCodeResponseSchema)` to create a new message. */ export const IssueClientAuthorizationCodeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 22); + messageDesc(file_saas_accounts_v1_authentication, 23); /** * AuthorizationCodeGrant redeems a code issued by IssueClientAuthorizationCode. @@ -673,7 +717,7 @@ export type AuthorizationCodeGrant = Message<"saas.accounts.v1.AuthorizationCode * Use `create(AuthorizationCodeGrantSchema)` to create a new message. */ export const AuthorizationCodeGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 23); + messageDesc(file_saas_accounts_v1_authentication, 24); /** * ClientRefreshTokenGrant rotates a client's own refresh token. The presented @@ -694,7 +738,7 @@ export type ClientRefreshTokenGrant = Message<"saas.accounts.v1.ClientRefreshTok * Use `create(ClientRefreshTokenGrantSchema)` to create a new message. */ export const ClientRefreshTokenGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 24); + messageDesc(file_saas_accounts_v1_authentication, 25); /** * @generated from message saas.accounts.v1.ExchangeClientTokenRequest @@ -728,7 +772,7 @@ export type ExchangeClientTokenRequest = Message<"saas.accounts.v1.ExchangeClien * Use `create(ExchangeClientTokenRequestSchema)` to create a new message. */ export const ExchangeClientTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 25); + messageDesc(file_saas_accounts_v1_authentication, 26); /** * ExchangeClientTokenResponse returns both halves in the body. A public client @@ -759,7 +803,7 @@ export type ExchangeClientTokenResponse = Message<"saas.accounts.v1.ExchangeClie * Use `create(ExchangeClientTokenResponseSchema)` to create a new message. */ export const ExchangeClientTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 26); + messageDesc(file_saas_accounts_v1_authentication, 27); /** * AuthService — JWT token issuance and session management @@ -839,12 +883,16 @@ export const AuthService: GenService<{ output: typeof SwitchOrganizationResponseSchema; }, /** + * Logout ends the host session and, when the identity provider publishes an + * `end_session_endpoint`, returns the RP-initiated logout URL for the client + * to complete. See module/AUTHENTICATION.md "Signing out". + * * @generated from rpc saas.accounts.v1.AuthService.Logout */ logout: { methodKind: "unary"; input: typeof LogoutRequestSchema; - output: typeof EmptySchema; + output: typeof LogoutResponseSchema; }, /** * @generated from rpc saas.accounts.v1.AuthService.GetJWKS diff --git a/scripts/ci/check-generated-libraries.sh b/scripts/ci/check-generated-libraries.sh index 6f557f819..a541a211b 100755 --- a/scripts/ci/check-generated-libraries.sh +++ b/scripts/ci/check-generated-libraries.sh @@ -8,10 +8,19 @@ repo_root="$(cd "$(dirname "$0")/../.." && pwd)" cd "$repo_root" # The source-read SDK, exactly as libraries/source-read-sdk/README.md documents. +# +# --force because this script's whole job is to reproduce the checked-in tree: +# `generate client` refuses by default to re-point a library at a contract whose +# digest moved, which is exactly the case a reproduction check has to handle. +# Without it the gate could never run again after any proto change reached this +# endpoint — it failed with "use --force to overwrite" rather than with a diff, +# so the one thing it exists to detect became unreachable. The `git diff` below +# is what makes overwriting safe: a regeneration that differs from the commit +# still fails. codefly generate client --from saas-starter/accounts/authority --name source-read-sdk \ --language go --services ModuleCapabilitiesService \ --go-module github.com/codefly-dev/module-saas-starter/libraries/source-read-sdk/go \ - --output libraries/source-read-sdk + --output libraries/source-read-sdk --force # The TypeScript SDK, through its own generator script (both steps). (cd module/services/frontend/code/packages/saas-sdk && node scripts/generate.mjs --force) From 4b7063b1ccf23e991b4c0f7833f63ddf5bcb6023 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:27:12 -0500 Subject: [PATCH 03/12] chore(frontend): saas-sdk 0.3.43 for the Logout response contract The generated TypeScript now carries LogoutResponse, so the published tarball would differ from the bytes 0.3.42 already serves and the registry would refuse it. Additive, so a patch. Co-Authored-By: Claude Opus 5 --- module/services/frontend/code/packages/saas-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index b7791c0a9..efccbf7cc 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.42", + "version": "0.3.43", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", From fa212b7a232a4cb4d300f7719dba104806c547af Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:47:06 -0500 Subject: [PATCH 04/12] feat(accounts): a scheduled, leased, idempotent daily audit archive with a receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExportAuditLog` has always let an administrator download their own organisation's audit log on request. Nothing ran it on a schedule into a store outside the database, so the only copy of the audit trail was the Postgres table it was written to — which the same operator who can change a record can also change. With `audit-archive/AUDIT_ARCHIVE_URI` set, the host's daily sweep offers every complete UTC day in a seven-day catch-up window on a new `audit_archive` queue, and a leased worker exports that day: the whole platform's audit log, every organisation plus the control plane, as one object. The sweep only enqueues — exporting inline would hold the daily tick for as long as the slowest object store takes, and an object-storage outage would stall the retention pass that shares the tick. The window rather than only yesterday is what makes a host that was down over a weekend archive the days it missed instead of losing them silently, and it costs nothing because every day in it is deduplicated. Idempotent per day, twice over and independently: the job's idempotency key is the day, so the sweep may offer the same day for ever without producing a second job, and the write is create-if-absent, so even a job that outlived its own record cannot replace an archived day's bytes. A key already present is a success with no second receipt — the first archiving wrote one, and a receipt whose digest described bytes this run did not write would be a lie. The receipt is `saas.audit.archive_exported` in the audit log itself: the day, the object key, the SHA-256 digest of exactly the bytes written, and the byte and event counts. That is what makes "was yesterday archived, and is the object in the bucket the object we wrote" answerable from the trail rather than from the bucket alone, which is the thing the archive exists to stop being the single copy. The destination is the composed object-storage service's own bucket, reached through a new unbound writer beside the per-call-bound reader that package already has: that service's Put carries no bucket, so the bucket, its credentials and its retention policy stay with the composition. `AUDIT_ARCHIVE_URI` is therefore a key PREFIX, and a value naming a bucket, a host or a scheme fails the boot rather than writing somewhere the operator did not choose. A configured destination with no object-storage service composed also fails the boot: the operator asked for an archive and would otherwise get silence. **"Sealed" means create-if-absent plus a digest receipt, not WORM.** The object cannot be silently replaced by this host, and a replacement by anything else is detectable against the receipt. It is not immutable: that needs an object-lock or retention policy on the bucket, which belongs to the infrastructure that provisions it and does not exist yet. AUTHZ.md, module/AUDIT_METRICS.md, module/JOBS.md and CLAIM_INVENTORY.md all say tamper-evident rather than tamper-proof, in those words. `PostgresStore` keeps one behaviour change of its own from this: a day with no events is still archived, as an empty array, so the absence of an object means "not archived" and nothing else. Co-Authored-By: Claude Opus 5 --- AUTHZ.md | 33 ++ CLAIM_INVENTORY.md | 1 + configurations/local/audit-archive.env | 1 + module/AUDIT_METRICS.md | 16 + module/JOBS.md | 43 ++ module/configurations/local/audit-archive.env | 26 ++ .../accounts/saas.events.codefly.yaml | 4 + .../pkg/adapters/rpc_policy_shadow_test.go | 15 +- .../code/pkg/business/audit_archive.go | 406 ++++++++++++++++++ .../code/pkg/business/audit_archive_test.go | 276 ++++++++++++ .../code/pkg/business/audit_parity_test.go | 5 + .../code/pkg/business/audit_registry.go | 18 + .../accounts/code/pkg/business/service.go | 1 + .../pkg/datasource/objectstore/archive.go | 113 +++++ module/services/accounts/code/work.go | 64 +++ module/services/accounts/service.codefly.yaml | 3 + 16 files changed, 1024 insertions(+), 1 deletion(-) create mode 120000 configurations/local/audit-archive.env create mode 100644 module/configurations/local/audit-archive.env create mode 100644 module/services/accounts/code/pkg/business/audit_archive.go create mode 100644 module/services/accounts/code/pkg/business/audit_archive_test.go create mode 100644 module/services/accounts/code/pkg/datasource/objectstore/archive.go diff --git a/AUTHZ.md b/AUTHZ.md index 05d4b1560..3c281218c 100644 --- a/AUTHZ.md +++ b/AUTHZ.md @@ -354,6 +354,39 @@ ROLE`) with its own explicit grants, which a request transaction has no way to reach. Each entry is recorded (`recordControlPlane`), so the crossings are countable rather than merely greppable. +### The daily archive, and what "sealed" currently means + +With `audit-archive/AUDIT_ARCHIVE_URI` set, the host writes one object per +complete UTC day — the whole platform's audit log, every organisation plus the +control plane — to the composed object-storage service's own bucket, and +records a receipt for each archived day in the audit log itself: the day, the +object key, its SHA-256 digest, and the byte and event counts +(`saas.audit.archive_exported`). The host's daily sweep offers each day; a +leased job performs the export. Two independent guards make it idempotent per +day: the job's idempotency key is the day, and the write is create-if-absent, so +even a job that outlived its own record cannot replace an archived day's bytes. + +**State plainly what this does and does not prove.** + +- It does prove the audit trail has a copy outside the database it was written + to, and that a later run of this host cannot silently rewrite an archived day. +- It does prove a tampered object is *detectable*: the receipt's digest + describes exactly the bytes that were written, and the receipt is in the audit + log rather than only in the bucket. +- It does **not** prove the object is immutable. That needs an object-lock or + retention policy on the bucket, which belongs to the infrastructure that + provisions the bucket and not to this module — accounts names a key prefix and + never a bucket, precisely so it makes no claim about a bucket's policy. Until + that lock exists, the archive is tamper-**evident** and not + tamper-**proof**, and anyone with write access to the bucket can replace an + object; what they cannot do is make the replacement agree with the receipt. +- A day with no events is still archived, as an empty array. The absence of an + object means "not archived" and nothing else. + +A day older than the sweep's catch-up window is not archived automatically; it +is `ExportAuditLog` for an operator to run. Unset, the key leaves the audit +log's only copy in this database, and the boot says so. + ## Removing an organization member: deleted vs. rendered ineffective Organization membership is the root of a member's authority in a tenant, and diff --git a/CLAIM_INVENTORY.md b/CLAIM_INVENTORY.md index e5867005e..7cd398357 100644 --- a/CLAIM_INVENTORY.md +++ b/CLAIM_INVENTORY.md @@ -45,6 +45,7 @@ must not be inferred from a row here. See | ~~`users` / `mfa_devices` / `notifications` / `oauth_state` / `refresh_tokens` are outside RLS, protected by `WHERE user_id = $1`~~ | MODULE.md skip-list and AUTHZ.md skip-list, before this change | the first three carry forced RLS; the last two do not exist | **corrected** | #541 | | The documented relation inventory matches the executable one | module/DATABASE_AUTHORITY.md § Scope and RLS inventory | `TestDatabaseAuthorityScopeInventoryMatchesCode` (`module/tools`) fails on drift in either direction | implemented | #541 | | `GetServiceInfo`'s RLS catalog (`serviceRLSTables`) reports the current protected set | `pkg/business/introspection.go` | **stale**: 25 hand-maintained entries against 38 tenant + 13 user relations, sourced "store migrations through 60" | known drift, filed | #556 | +| The daily audit archive is "sealed" | AUTHZ.md § The daily archive, `pkg/business/audit_archive.go` | the object is written create-if-absent and its SHA-256 digest is receipted in the audit log, so a replacement is **detectable**; the object is **not immutable** — that needs an object-lock/retention policy on the bucket, which belongs to the infrastructure that provisions it and does not exist yet. Read "sealed" as tamper-evident, never as WORM | **configured**, with the immutability half **planned** elsewhere | accounts | | Scope-node role grants and installation identity ship | PLATFORM_REFERENCE.md §1.2 | `98_layered_access`, `112_installations`, `pkg/business/installations.go` | implemented | accounts | | RLS table→migration adoption table | AUTHZ.md § RLS coverage | names `audit_export_configs`, dropped by store migration 102 | historical (labelled) | #541 | diff --git a/configurations/local/audit-archive.env b/configurations/local/audit-archive.env new file mode 120000 index 000000000..5692c0c55 --- /dev/null +++ b/configurations/local/audit-archive.env @@ -0,0 +1 @@ +../../module/configurations/local/audit-archive.env \ No newline at end of file diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md index 399990a06..1cf17880e 100644 --- a/module/AUDIT_METRICS.md +++ b/module/AUDIT_METRICS.md @@ -13,6 +13,22 @@ must not join or JSON-encode the arrays into strings. Audit schema validation warnings are advisory and do not by themselves reject an installation or drop its audit record. Authorization and transaction failures remain separate. +## The daily archive is not a metric, and not immutable either + +`audit-archive/AUDIT_ARCHIVE_URI` turns on a scheduled export of each complete +UTC day's audit log to the composed object-storage service's own bucket, with a +receipt per day in the audit log (`saas.audit.archive_exported`: the day, the +object key, its digest, the byte and event counts). It is not part of the +scoped-metric surface this document describes and nothing here reads it. + +It is named here only to keep one claim from being made by omission: the archive +is tamper-**evident**, not tamper-**proof**. The write is create-if-absent and +the receipt carries the digest, so a replaced object disagrees with the trail — +but the object itself is immutable only once the bucket carries an object-lock +or retention policy, which belongs to the infrastructure that provisions the +bucket and not to this module. [AUTHZ.md](../AUTHZ.md) § "The daily archive, and +what \"sealed\" currently means" is the full statement. + ## Configuration recipes The SDK supplies `orgId`, `from`, and `to` through `runDashboard`'s viewer context. diff --git a/module/JOBS.md b/module/JOBS.md index 3a51b7aa7..f10f4239d 100644 --- a/module/JOBS.md +++ b/module/JOBS.md @@ -413,6 +413,49 @@ OpenTelemetry. Queue and bounded result/outcome are the only labels. Durable queue depth, oldest age, retries, terminal failures, and replay lineage remain in the generic Postgres operations projection. +## Daily audit archive adapter + +The audit archive is the one workload the platform *schedules* rather than +accepts from a request. The host's daily sweep offers every complete UTC day in +a seven-day catch-up window on queue `audit_archive`, topic +`audit.archive.export`, source `saas.audit`, schema version `1`, with the day +itself as the idempotency key; a leased worker exports that day and writes a +receipt. Nothing is offered or run unless `audit-archive/AUDIT_ARCHIVE_URI` +names a destination. + +The day being the idempotency key is what makes the sweep safe to run on every +tick for ever: the store admits the first offer of a day and drops every later +one, so a tick after the first costs one refused insert. The window rather than +only yesterday is what makes a host that was down over a weekend archive the +days it missed instead of losing them silently, and it costs nothing because +every day in it is already deduplicated. A day older than the window is an +operator's `ExportAuditLog` to run by hand. + +The sweep only enqueues. Exporting inline would hold the daily tick for as long +as the slowest object store takes, and an object-storage outage would then stall +the retention pass that shares the tick. + +Failure classification, which is the part worth knowing: + +- a store refusal is **retryable**, on a schedule that spends the eight-attempt + budget over roughly four hours — long enough to outlast an ordinary + object-store incident, short enough to reach a dead letter the same day; +- a key already present is a **success** with no second receipt. That is the + second idempotency guard — the one that holds after the job record has been + reaped — and the first archiving already wrote the receipt, so writing another + whose digest described bytes this run did not write would be a lie; +- a malformed envelope, a day whose payload and idempotency key disagree, and a + job whose destination was cleared while it was queued are each **permanent**: + no amount of retrying makes any of them executable; +- an object written whose receipt could not be recorded is **permanent** too, + and deliberately so: a retry re-runs the export, meets the create-if-absent + refusal and returns without a receipt, so retrying cannot repair it. It + dead-letters where an operator sees it. + +What "sealed" means, and does not, is in [../AUTHZ.md](../AUTHZ.md) § "The daily +archive, and what \"sealed\" currently means". Short version: tamper-evident, +not immutable. + ## Privacy workflow adapter Privacy export and deletion run on this platform instead of a detached diff --git a/module/configurations/local/audit-archive.env b/module/configurations/local/audit-archive.env new file mode 100644 index 000000000..16bef060b --- /dev/null +++ b/module/configurations/local/audit-archive.env @@ -0,0 +1,26 @@ +# The daily audit archive (issue #1296). +# +# AUDIT_ARCHIVE_URI is the key prefix every archived day is written under, +# inside the composed object-storage service's own bucket. Unset means no +# archive is configured: nothing is scheduled, no worker runs, and the audit +# log's only copy is the database table — which is the state this key exists to +# leave. +# +# It names a PREFIX and never a bucket. The object-storage service's write +# carries no bucket, so the bucket, its credentials and its retention policy +# belong to the composition that points that service at one; a value naming a +# bucket, a host or a scheme (`s3://…`) fails the boot rather than writing +# somewhere the operator did not choose. +# +# (unset) — no archive. +# audit/archive — /audit/archive/YYYY/MM/DD/audit-YYYY-MM-DD.json +# +# The object is written create-if-absent, so an archived day's bytes cannot be +# replaced by a later run, and each day's receipt — key, digest, byte and event +# counts — is recorded in the audit log itself. That makes the archive +# tamper-EVIDENT. Making it tamper-PROOF needs an object-lock/retention policy +# on the bucket, which belongs to the infrastructure that provisions it; see +# module/AUDIT_METRICS.md. +# +# Unset on this profile: a developer's local object store is not an archive. +AUDIT_ARCHIVE_URI= diff --git a/module/contracts/accounts/saas.events.codefly.yaml b/module/contracts/accounts/saas.events.codefly.yaml index 0ef82f641..c6de7e1e7 100644 --- a/module/contracts/accounts/saas.events.codefly.yaml +++ b/module/contracts/accounts/saas.events.codefly.yaml @@ -59,6 +59,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.audit.archive_exported + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.auth.client_authorized schema: saas/events/v1/events.proto#EventEnvelope visibility: external diff --git a/module/services/accounts/code/pkg/adapters/rpc_policy_shadow_test.go b/module/services/accounts/code/pkg/adapters/rpc_policy_shadow_test.go index b2cc8696f..a8b6b9940 100644 --- a/module/services/accounts/code/pkg/adapters/rpc_policy_shadow_test.go +++ b/module/services/accounts/code/pkg/adapters/rpc_policy_shadow_test.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "sync" "testing" "github.com/stretchr/testify/require" @@ -20,7 +21,19 @@ func TestShadowPolicyCoverageRecordsEveryOutcomeIncludingOK(t *testing.T) { reader := sdkmetric.NewManualReader() provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) otel.SetMeterProvider(provider) - t.Cleanup(func() { otel.SetMeterProvider(metricnoop.NewMeterProvider()) }) + // Rebind the counter against this provider. rpcPolicyCoverageCounter binds + // once, lazily, so any earlier test in this package that ran an interceptor + // — the host-interface conformance harness does — has already bound it to + // whichever provider was global then, and this case would collect an empty + // reader and fail (or, worse on another ordering, pass having measured + // nothing). + coverageCounterOnce = sync.Once{} + coverageCounter = nil + t.Cleanup(func() { + otel.SetMeterProvider(metricnoop.NewMeterProvider()) + coverageCounterOnce = sync.Once{} + coverageCounter = nil + }) // GetUser is a covered self-service read (ok); CreateAPIKey requires an org // admin the interceptor does not yet resolve (gap). diff --git a/module/services/accounts/code/pkg/business/audit_archive.go b/module/services/accounts/code/pkg/business/audit_archive.go new file mode 100644 index 000000000..5117a8590 --- /dev/null +++ b/module/services/accounts/code/pkg/business/audit_archive.go @@ -0,0 +1,406 @@ +package business + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/codefly-dev/core/wool" + + "accounts/pkg/datasource/objectstore" + jobsv1 "accounts/pkg/gen/saas/jobs/v1" + "accounts/pkg/jobs" +) + +// The daily audit archive. +// +// `ExportAuditLog` has always let an administrator download their own +// organisation's audit log on request. Nothing ran it on a schedule into a +// store outside the database, so the only copy of the audit trail was the +// Postgres table it was written to — which the same operator who can change a +// record can also change. +// +// The archive closes that. Its contract, and the parts of it that are +// deliberately not what a reader might assume: +// +// - One object per complete UTC day, under the configured prefix, in the +// object-storage service's own bucket. Accounts names the key; the bucket, +// its credentials and its retention posture belong to the composition. +// - Scheduled by the host's daily sweep, performed by a leased job. The sweep +// only enqueues; the export itself runs off the tick, under a lease, with a +// retry budget, so a slow store or a large day cannot wedge the sweep. +// - Idempotent per day, twice over and independently: the job's idempotency +// key is the day, so the sweep may enqueue the same day for ever without +// producing a second job, and the write is create-if-absent, so even a job +// that outlived its own record cannot replace an archived day's bytes. +// - A receipt per archived day in the audit log itself — the day, the key, the +// digest, the byte and row counts — so "was yesterday archived, and is the +// object in the bucket the object we wrote" is answerable from the trail +// rather than from the bucket alone. +// +// **Sealed means create-if-absent plus a digest receipt, not WORM.** The object +// cannot be silently replaced by this host, and a replacement by anything else +// is detectable against the receipt's digest. It is not immutable: that needs an +// object-lock/retention policy on the bucket, which belongs to the +// infrastructure that provisions it and does not exist yet. Until it does, the +// archive is tamper-*evident* and not tamper-*proof*, and module/AUDIT_METRICS.md +// says so in the same words. +const ( + AuditArchiveQueue = "audit_archive" + AuditArchiveTopic = "audit.archive.export" + AuditArchiveSource = "saas.audit" + AuditArchiveSchemaVersion = 1 + // Eight attempts over the generic worker's backoff outlasts an + // object-storage outage of a few hours, which is the failure this retries + // for; beyond that an operator has a dead letter to look at rather than a + // job quietly retrying for a week. + AuditArchiveMaxAttempts = 8 + AuditArchiveContentType = "application/json" + // auditArchiveCatchUpDays is how many complete days each sweep offers. The + // enqueue is deduplicated by day, so offering a window rather than only + // yesterday is what makes a host that was down over a weekend archive the + // days it missed instead of losing them silently. A day older than the + // window is an operator's `ExportAuditLog` to run by hand. + auditArchiveCatchUpDays = 7 +) + +// AuditArchiveRetryDelay spaces the attempts. The archive is not on anyone's +// request path and holds no ordering key, so unlike the privacy workflow it can +// afford to wait: the schedule spends the eight-attempt budget over roughly +// four hours, which outlasts an ordinary object-store incident, and anything +// longer than that is a dead letter an operator should be looking at rather +// than a job still hoping. +func AuditArchiveRetryDelay(attempt uint32) time.Duration { + schedule := [...]time.Duration{ + time.Minute, + 5 * time.Minute, + 15 * time.Minute, + 30 * time.Minute, + time.Hour, + time.Hour, + time.Hour, + } + index := 0 + if attempt > 0 { + index = min(int(attempt-1), len(schedule)-1) + } + return schedule[index] +} + +// AuditArchiveStore is the object store the archive writes to: one +// create-if-absent write, and nothing else. The host's object-storage client +// implements it (objectstore.HostStore); a test scripts it. +// +// PutIfAbsent must report objectstore.ErrObjectExists — matched here through +// ErrAuditArchiveObjectExists — for a key already present, because that is the +// signal the day is already archived rather than a failure to archive it. +type AuditArchiveStore interface { + PutIfAbsent(ctx context.Context, key, contentType string, body []byte) (etag string, err error) +} + +// ErrAuditArchiveObjectExists is the sentinel the archiver recognises as "this +// day is already in the store". It IS the object store's own error rather than +// a second one translated into it: a sentinel the implementation does not +// return is a branch that never runs, and that branch is the second of the two +// idempotency guards — the one that holds when the first has been reaped. +var ErrAuditArchiveObjectExists = objectstore.ErrObjectExists + +// AuditArchive is the configured destination, resolved once at startup. An +// empty Prefix means no archive is configured and nothing is scheduled. +type AuditArchive struct { + // Prefix is the key prefix every archived day is written under, from + // AUDIT_ARCHIVE_URI. It names no bucket: PutIfAbsent lands in the + // object-storage service's own bucket, so a bucket named here would be a + // claim this host cannot honour. + Prefix string + // Store is the writer. Nil with a non-empty Prefix is a misconfiguration + // the boot refuses, not a silent no-op. + Store AuditArchiveStore +} + +// ParseAuditArchiveURI reads AUDIT_ARCHIVE_URI into a key prefix. +// +// It accepts a bare prefix ("audit/archive", "audit/archive/") and refuses +// anything that names a bucket, a host or a scheme. The object-storage +// service's Put carries no bucket — the bucket is the composition's, configured +// on that service — so accepting `s3://somewhere-else/audit` would write to a +// bucket the operator did not name while reading as though it had. A boot +// refusal naming the problem is the only honest answer. +func ParseAuditArchiveURI(raw string) (string, error) { + prefix := strings.TrimSpace(raw) + if prefix == "" { + return "", nil + } + if parsed, err := url.Parse(prefix); err == nil && (parsed.Scheme != "" || parsed.Host != "") { + return "", fmt.Errorf( + "AUDIT_ARCHIVE_URI must be a key prefix inside the composed object-storage service's own bucket, not a URI naming a bucket or host (%q): the bucket, its credentials and its retention policy belong to the composition, and this host cannot write to another one", + raw) + } + if strings.HasPrefix(prefix, "/") { + return "", fmt.Errorf("AUDIT_ARCHIVE_URI must not begin with %q: a store key prefix is literal, and a leading slash makes every key one too (%q)", "/", raw) + } + return strings.TrimSuffix(prefix, "/"), nil +} + +// SetAuditArchive wires the archive destination. Called once at startup. +func (s *Service) SetAuditArchive(archive AuditArchive) { s.auditArchive = archive } + +// AuditArchiveConfigured reports whether a destination is wired, which is what +// decides whether the sweep and the worker run at all. +func (s *Service) AuditArchiveConfigured() bool { + return s.auditArchive.Prefix != "" && s.auditArchive.Store != nil +} + +// auditArchivePayload is the job payload: the day, and nothing else. The day +// determines the key, the query window and the idempotency key, so a job +// carrying anything more would let those three disagree. +type auditArchivePayload struct { + Day string `json:"day"` +} + +// auditArchiveDay is the canonical spelling of a day everywhere in the +// archive: the payload, the idempotency key, the object key and the receipt. +const auditArchiveDay = "2006-01-02" + +// auditArchiveActor is the actor on the receipt. No person asked for a +// scheduled archive, and attributing it to one would put a name in the trail +// that did nothing. +const auditArchiveActor = "system" + +// AuditArchiveKey is the object key for one day. The date path makes a +// lifecycle rule, a listing and a human all read the same way, and the day is +// in the file name too so an object pulled out of the bucket on its own still +// says which day it is. +func AuditArchiveKey(prefix, day string) string { + return fmt.Sprintf("%s/%s/audit-%s.json", prefix, strings.ReplaceAll(day, "-", "/"), day) +} + +// newAuditArchiveJob offers one day for archiving. The idempotency key is the +// day, which is what makes the sweep safe to run on every tick for ever: the +// job store admits the first offer of a day and drops every later one. +func newAuditArchiveJob(day string) (*jobsv1.EnqueueJobRequest, error) { + payload, err := json.Marshal(auditArchivePayload{Day: day}) + if err != nil { + return nil, err + } + request := &jobsv1.EnqueueJobRequest{Job: &jobsv1.NewJob{ + Direction: jobsv1.JobDirection_JOB_DIRECTION_OUTBOX, + Scope: &jobsv1.JobScope{Value: &jobsv1.JobScope_Global{Global: true}}, + Queue: AuditArchiveQueue, + Topic: AuditArchiveTopic, + Source: AuditArchiveSource, + IdempotencyKey: "audit-archive-" + day, + SchemaVersion: AuditArchiveSchemaVersion, + Payload: payload, + ContentType: AuditArchiveContentType, + MaxAttempts: AuditArchiveMaxAttempts, + }} + if err := jobs.ValidateCommand(request); err != nil { + return nil, err + } + return request, nil +} + +func decodeAuditArchiveEnvelope(envelope *jobsv1.JobEnvelope) (string, error) { + if err := jobs.ValidateCommand(envelope); err != nil { + return "", err + } + scope, global := envelope.GetScope().GetValue().(*jobsv1.JobScope_Global) + if envelope.GetDirection() != jobsv1.JobDirection_JOB_DIRECTION_OUTBOX || + !global || !scope.Global || + envelope.GetQueue() != AuditArchiveQueue || + envelope.GetTopic() != AuditArchiveTopic || + envelope.GetSource() != AuditArchiveSource || + envelope.GetSchemaVersion() != AuditArchiveSchemaVersion || + envelope.GetContentType() != AuditArchiveContentType { + return "", errors.New("audit archive: unexpected job routing") + } + var payload auditArchivePayload + if err := json.Unmarshal(envelope.GetPayload(), &payload); err != nil { + return "", err + } + if _, err := time.Parse(auditArchiveDay, payload.Day); err != nil { + return "", fmt.Errorf("audit archive: %q is not a day", payload.Day) + } + // The day decides the key and the window, so a job whose payload day and + // whose idempotency key disagree would archive one day under another's name. + if envelope.GetIdempotencyKey() != "audit-archive-"+payload.Day { + return "", errors.New("audit archive: the job's day and its idempotency key disagree") + } + return payload.Day, nil +} + +// ScheduleAuditArchive offers every complete day in the catch-up window for +// archiving and returns how many were newly accepted. It performs no export: a +// sweep that exported inline would hold the daily tick for as long as the +// slowest store takes, and an object-storage outage would stall the retention +// pass that shares the tick. +// +// now is the caller's clock, so a test need not wait for midnight. +func (s *Service) ScheduleAuditArchive(ctx context.Context, now time.Time) (int, error) { + if !s.AuditArchiveConfigured() { + return 0, nil + } + producer, ok := s.store.(jobs.Producer) + if !ok { + return 0, errors.New("audit archive: the store cannot enqueue jobs") + } + w := wool.Get(ctx).In("ScheduleAuditArchive") + var offered int + var failures []error + // Yesterday first, then backwards: the day an operator is waiting for is + // the most recent one, and a window whose older end fails should not stop + // it being offered. + for back := 1; back <= auditArchiveCatchUpDays; back++ { + day := now.UTC().AddDate(0, 0, -back).Format(auditArchiveDay) + request, err := newAuditArchiveJob(day) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", day, err)) + continue + } + accepted, err := producer.EnqueueJob(ctx, request) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", day, err)) + continue + } + // A day already offered is the ordinary case on every tick after the + // first, and says nothing; a newly inserted one is worth counting. + if accepted.GetDisposition() == jobsv1.JobEnqueueDisposition_JOB_ENQUEUE_DISPOSITION_INSERTED { + offered++ + w.Info("audit archive day offered", wool.Field("day", day)) + } + } + return offered, errors.Join(failures...) +} + +// NewAuditArchiveJobHandler exports one leased day. +func (s *Service) NewAuditArchiveJobHandler() jobs.Handler { + return func(ctx context.Context, envelope *jobsv1.JobEnvelope) error { + day, err := decodeAuditArchiveEnvelope(envelope) + if err != nil { + // A job that can never execute is a permanent failure: retrying a + // malformed envelope eight times only delays the dead letter an + // operator has to look at anyway. + return errors.Join(err, jobs.NewProcessingError( + "audit_archive.invalid_job", "unexpected audit archive job", false)) + } + return s.archiveAuditDay(ctx, day) + } +} + +// archiveAuditDay exports one complete UTC day and records its receipt. +func (s *Service) archiveAuditDay(ctx context.Context, day string) error { + w := wool.Get(ctx).In("archiveAuditDay").With(wool.Field("day", day)) + if !s.AuditArchiveConfigured() { + // The job outlived its configuration — an operator cleared + // AUDIT_ARCHIVE_URI while a day was still queued. There is nowhere to + // write, and no amount of retrying creates one. + return jobs.NewProcessingError("audit_archive.not_configured", + "no audit archive destination is configured", false) + } + body, rows, err := s.auditDayExport(ctx, day) + if err != nil { + return err + } + key := AuditArchiveKey(s.auditArchive.Prefix, day) + digest := sha256.Sum256(body) + etag, err := s.auditArchive.Store.PutIfAbsent(ctx, key, AuditArchiveContentType, body) + switch { + case errors.Is(err, ErrAuditArchiveObjectExists): + // The day is archived. This is the second idempotency guard doing its + // job — a job that outlived its own record, or two workers racing the + // same lease hand-off — and it is a success, not a failure. No second + // receipt: the first archiving already wrote one, and a receipt whose + // digest describes bytes this run did not write would be a lie. + w.Info("audit archive day was already in the store", wool.Field("key", key)) + return nil + case err != nil: + // Retryable: an object store that refuses today may accept in a minute, + // and the attempt budget is what decides when to give up. + return fmt.Errorf("write the audit archive object %s: %w", key, err) + } + // The receipt goes in the audit log itself, under the control plane, after + // the object is durable. Written before, it would claim an archive that may + // not exist; the opposite order can only lose a receipt for an object that + // is there, which the digest in the store's own metadata and a re-run make + // recoverable. + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + return s.emitTx(ctx, auditArchiveActor, ActorTypeSystem, EventAuditArchiveExported, "audit_archive", key, "", + map[string]any{ + "day": day, + "object_key": key, + "digest": "sha256:" + hex.EncodeToString(digest[:]), + "bytes": len(body), + "events": rows, + "etag": etag, + }) + }); err != nil { + // The object is written and the receipt is not. Retrying re-runs the + // export, meets ErrObjectExists and returns without a receipt, so a + // retry cannot repair this — say so plainly and let it dead-letter + // where an operator sees it, rather than burn the attempt budget. + w.Error("the audit archive object is written but its receipt is not", wool.ErrField(err), wool.Field("key", key)) + return errors.Join(err, jobs.NewProcessingError("audit_archive.receipt_failed", + "the archive object is written but its receipt could not be recorded", false)) + } + w.Info("audit archive day exported", + wool.Field("key", key), wool.Field("bytes", len(body)), wool.Field("events", rows)) + return nil +} + +// auditDayExport serialises one complete UTC day of the whole platform's audit +// log, and returns the bytes and how many events they carry. +// +// It is the platform-wide export — every organisation plus the control plane — +// because that is what an archive of the audit trail is. The per-organisation +// download `ExportAuditLog` serves is a different question with a different +// authorisation, and reusing it here would archive one tenant at a time under +// a tenant's own scope. +func (s *Service) auditDayExport(ctx context.Context, day string) ([]byte, int, error) { + from, err := time.Parse(auditArchiveDay, day) + if err != nil { + return nil, 0, jobs.NewProcessingError("audit_archive.invalid_day", "not a day", false) + } + from = from.UTC() + resolver := s.AuditEventResolver() + entries := make([]auditExportEntry, 0, 256) + pageToken := "" + for { + until := from.AddDate(0, 0, 1) + page, next, _, err := s.QueryAuditLog(ctx, AuditQuery{ + From: &from, + To: &until, + PageSize: 500, + PageToken: pageToken, + }) + if err != nil { + return nil, 0, fmt.Errorf("query the audit log for %s: %w", day, err) + } + for _, entry := range page { + resolved, err := resolver.Resolve(ctx, entry.EventType) + if err != nil { + return nil, 0, fmt.Errorf("resolve %q for %s: %w", entry.EventType, day, err) + } + entries = append(entries, auditEntryToExport(entry, resolved)) + } + if next == "" || len(page) == 0 { + break + } + pageToken = next + } + // A day with no events is still archived, as an empty array. The absence of + // an object must mean "not archived" and nothing else: a day skipped + // because it was quiet is indistinguishable, in the bucket, from a day + // whose export never ran. + body, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return nil, 0, err + } + return body, len(entries), nil +} diff --git a/module/services/accounts/code/pkg/business/audit_archive_test.go b/module/services/accounts/code/pkg/business/audit_archive_test.go new file mode 100644 index 000000000..f3bd54757 --- /dev/null +++ b/module/services/accounts/code/pkg/business/audit_archive_test.go @@ -0,0 +1,276 @@ +package business + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "accounts/pkg/datasource/objectstore" + jobsv1 "accounts/pkg/gen/saas/jobs/v1" + "accounts/pkg/jobs" +) + +// AUDIT_ARCHIVE_URI names a prefix inside the composed object-storage +// service's own bucket. A value naming a bucket, a host or a scheme is refused +// at the boot that reads it: the service's write carries no bucket, so +// accepting `s3://somewhere/audit` would write to the composition's bucket +// while reading as though it had honoured the operator's. +func TestParseAuditArchiveURI(t *testing.T) { + for _, testCase := range []struct { + raw, want string + refused bool + }{ + {raw: "", want: ""}, + {raw: " ", want: ""}, + {raw: "audit/archive", want: "audit/archive"}, + {raw: "audit/archive/", want: "audit/archive"}, + {raw: " audit/archive ", want: "audit/archive"}, + {raw: "s3://acme-audit/archive", refused: true}, + {raw: "gs://acme-audit", refused: true}, + {raw: "https://store.example.com/audit", refused: true}, + {raw: "//acme-audit/archive", refused: true}, + {raw: "/audit/archive", refused: true}, + } { + t.Run(fmt.Sprintf("%q", testCase.raw), func(t *testing.T) { + prefix, err := ParseAuditArchiveURI(testCase.raw) + if testCase.refused { + require.Error(t, err) + require.Contains(t, err.Error(), "AUDIT_ARCHIVE_URI") + return + } + require.NoError(t, err) + require.Equal(t, testCase.want, prefix) + }) + } +} + +// The key puts the day in the path and in the file name: the path so a +// lifecycle rule and a listing read the same way, the file name so an object +// pulled out of the bucket on its own still says which day it is. +func TestAuditArchiveKey(t *testing.T) { + require.Equal(t, + "audit/archive/2026/11/02/audit-2026-11-02.json", + AuditArchiveKey("audit/archive", "2026-11-02")) +} + +// The job's idempotency key is the day, which is what makes the sweep safe to +// run on every tick for ever. +func TestAuditArchiveJobIsKeyedByItsDay(t *testing.T) { + request, err := newAuditArchiveJob("2026-11-02") + require.NoError(t, err) + require.Equal(t, "audit-archive-2026-11-02", request.GetJob().GetIdempotencyKey()) + require.Equal(t, AuditArchiveQueue, request.GetJob().GetQueue()) + require.True(t, request.GetJob().GetScope().GetGlobal()) +} + +// An envelope whose payload day and whose idempotency key disagree would +// archive one day under another's name, so it is refused rather than run. +func TestAuditArchiveEnvelopeRefusesAMismatchedDay(t *testing.T) { + request, err := newAuditArchiveJob("2026-11-02") + require.NoError(t, err) + job := request.GetJob() + + envelope := &jobsv1.JobEnvelope{ + Id: NewIDString(), Direction: job.GetDirection(), Scope: job.GetScope(), + Queue: job.GetQueue(), Topic: job.GetTopic(), Source: job.GetSource(), + IdempotencyKey: job.GetIdempotencyKey(), SchemaVersion: job.GetSchemaVersion(), + Payload: job.GetPayload(), ContentType: job.GetContentType(), + State: jobsv1.JobState_JOB_STATE_PENDING, + MaxAttempts: job.GetMaxAttempts(), + } + day, err := decodeAuditArchiveEnvelope(envelope) + require.NoError(t, err) + require.Equal(t, "2026-11-02", day) + + envelope.IdempotencyKey = "audit-archive-2026-11-03" + _, err = decodeAuditArchiveEnvelope(envelope) + require.ErrorContains(t, err, "disagree") + + envelope.IdempotencyKey = job.GetIdempotencyKey() + envelope.Payload = []byte(`{"day":"not-a-day"}`) + _, err = decodeAuditArchiveEnvelope(envelope) + require.ErrorContains(t, err, "not a day") + + envelope.Payload = job.GetPayload() + envelope.Queue = "something-else" + _, err = decodeAuditArchiveEnvelope(envelope) + require.ErrorContains(t, err, "unexpected job routing") +} + +// The retry schedule spends the attempt budget over hours, not seconds: the +// failure it retries for is an object store being down, and the archive is on +// nobody's request path. +func TestAuditArchiveRetryDelayGrowsAndSettles(t *testing.T) { + require.Equal(t, time.Minute, AuditArchiveRetryDelay(0)) + require.Equal(t, time.Minute, AuditArchiveRetryDelay(1)) + require.Equal(t, 5*time.Minute, AuditArchiveRetryDelay(2)) + require.Equal(t, time.Hour, AuditArchiveRetryDelay(AuditArchiveMaxAttempts)) + require.Equal(t, time.Hour, AuditArchiveRetryDelay(1000)) + + var total time.Duration + for attempt := range uint32(AuditArchiveMaxAttempts) { + total += AuditArchiveRetryDelay(attempt) + } + require.Greater(t, total, 3*time.Hour, "the budget must outlast an ordinary store incident") + require.Less(t, total, 12*time.Hour, "and must reach a dead letter the same day") +} + +// Nothing is scheduled and nothing is written without a configured +// destination, and a store with no prefix is not a destination either: both +// halves have to be present or the feature is off. +func TestAuditArchiveIsOffWithoutBothHalves(t *testing.T) { + for _, archive := range []AuditArchive{ + {}, + {Prefix: "audit/archive"}, + {Store: &scriptedArchiveStore{}}, + } { + svc := &Service{auditArchive: archive} + require.False(t, svc.AuditArchiveConfigured()) + offered, err := svc.ScheduleAuditArchive(context.Background(), time.Now()) + require.NoError(t, err) + require.Zero(t, offered) + } +} + +// scriptedArchiveStore records what was written and can refuse as the store +// does for a key already present. +type scriptedArchiveStore struct { + objects map[string][]byte + exists bool + err error +} + +func (s *scriptedArchiveStore) PutIfAbsent(_ context.Context, key, _ string, body []byte) (string, error) { + if s.err != nil { + return "", s.err + } + if s.exists { + return "", fmt.Errorf("%w: scripted", objectstore.ErrObjectExists) + } + if s.objects == nil { + s.objects = map[string][]byte{} + } + s.objects[key] = body + return "etag-1", nil +} + +// A day already in the store is a success with no second receipt: the first +// archiving wrote one, and a receipt whose digest described bytes this run did +// not write would be a lie. This is the second idempotency guard — the one that +// holds after the job record has been reaped. +func TestArchivingADayAlreadyInTheStoreSucceedsWithoutASecondReceipt(t *testing.T) { + store := &scriptedArchiveStore{exists: true} + emitter := &recordingAuditEmitter{} + svc := &Service{ + auditArchive: AuditArchive{Prefix: "audit/archive", Store: store}, + store: &archiveQueryStore{}, + audit: emitter, + auditTx: emitter, + } + + require.NoError(t, svc.archiveAuditDay(context.Background(), "2026-11-02")) + require.Empty(t, emitter.entries) +} + +// The receipt carries the digest of exactly the bytes that were written, so +// "is the object in the bucket the object we wrote" is answerable from the +// trail and not from the bucket alone. +func TestArchivingADayWritesItAndReceiptsItsDigest(t *testing.T) { + store := &scriptedArchiveStore{} + emitter := &recordingAuditEmitter{} + svc := &Service{ + auditArchive: AuditArchive{Prefix: "audit/archive", Store: store}, + store: &archiveQueryStore{}, + audit: emitter, + auditTx: emitter, + } + + require.NoError(t, svc.archiveAuditDay(context.Background(), "2026-11-02")) + + key := "audit/archive/2026/11/02/audit-2026-11-02.json" + body, written := store.objects[key] + require.True(t, written, "the day's object is written under its own key") + // A day with no events is still archived, as an empty array: the absence of + // an object must mean "not archived" and nothing else. + var entries []auditExportEntry + require.NoError(t, json.Unmarshal(body, &entries)) + require.Empty(t, entries) + + require.Len(t, emitter.entries, 1) + receipt := emitter.entries[0] + require.Equal(t, EventAuditArchiveExported, receipt.EventType) + require.Equal(t, ActorTypeSystem, receipt.ActorType) + require.Equal(t, key, receipt.Payload["object_key"]) + require.Equal(t, "2026-11-02", receipt.Payload["day"]) + digest := sha256.Sum256(body) + require.Equal(t, "sha256:"+hex.EncodeToString(digest[:]), receipt.Payload["digest"]) + require.Equal(t, len(body), receipt.Payload["bytes"]) + require.Equal(t, 0, receipt.Payload["events"]) + // Every declared field, and only declared fields: an undeclared payload + // field is dropped on the way to the store, so the receipt would lose it. + require.NoError(t, ValidatePayload(EventAuditArchiveExported, receipt.Payload)) +} + +// A store refusal is retryable — it may accept in a minute, and the attempt +// budget decides when to give up — so it is returned as an ordinary error and +// no receipt is written for an object that is not there. +func TestAStoreRefusalIsRetryableAndWritesNoReceipt(t *testing.T) { + emitter := &recordingAuditEmitter{} + svc := &Service{ + auditArchive: AuditArchive{Prefix: "audit/archive", Store: &scriptedArchiveStore{ + err: fmt.Errorf("the store is unreachable"), + }}, + store: &archiveQueryStore{}, + audit: emitter, + auditTx: emitter, + } + + err := svc.archiveAuditDay(context.Background(), "2026-11-02") + require.ErrorContains(t, err, "write the audit archive object") + require.Empty(t, emitter.entries) +} + +// A day enqueued by a runtime that had a destination, run by one that no longer +// does, is a permanent failure: there is nowhere to write and no amount of +// retrying creates one. +func TestArchivingWithoutADestinationIsPermanent(t *testing.T) { + svc := &Service{store: &archiveQueryStore{}} + err := svc.archiveAuditDay(context.Background(), "2026-11-02") + require.ErrorContains(t, err, "audit_archive.not_configured") + var processing *jobs.ProcessingError + require.ErrorAs(t, err, &processing) + require.False(t, processing.Retryable, "there is nowhere to write, so retrying creates nothing") +} + +// archiveQueryStore answers the control-plane wrap and an empty audit log. +type archiveQueryStore struct { + Store +} + +func (s *archiveQueryStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *archiveQueryStore) QueryAuditLog(context.Context, AuditQuery) ([]AuditEntry, string, int32, error) { + return nil, "", 0, nil +} + +// recordingAuditEmitter captures what the archive receipted. +type recordingAuditEmitter struct { + entries []AuditEntry +} + +func (e *recordingAuditEmitter) Emit(_ context.Context, entry AuditEntry) { + e.entries = append(e.entries, entry) +} + +func (e *recordingAuditEmitter) EmitTx(_ context.Context, entry AuditEntry) error { + e.entries = append(e.entries, entry) + return nil +} diff --git a/module/services/accounts/code/pkg/business/audit_parity_test.go b/module/services/accounts/code/pkg/business/audit_parity_test.go index 8ac13957e..d0770dd99 100644 --- a/module/services/accounts/code/pkg/business/audit_parity_test.go +++ b/module/services/accounts/code/pkg/business/audit_parity_test.go @@ -14,6 +14,11 @@ import ( // nothing can reach. var eventsEmittedOutsideRPC = []EventType{ EventActivationAchieved, + // The daily audit archive's receipt: written by a leased job the host's own + // sweep enqueues, with no request behind it. There is deliberately no RPC + // that archives a day — a scheduled, idempotent export is not something a + // caller asks for. + EventAuditArchiveExported, // The authority core journals an uninstall in its own transaction; the // host interfaces declare no uninstall RPC (codefly.dev/installation has // none), so the host's own teardown is its one caller today. diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 8ac18d5c4..e1196cca1 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -433,6 +433,14 @@ const ( // release key. Its actor is the system: the signer, its ownership domain // and the payload digest name who stated the ceiling. EventModuleEnvelopeDelivered EventType = "saas.platform.module_envelope_delivered" + // EventAuditArchiveExported records that one complete UTC day of the whole + // platform's audit log was written to the configured archive. It is the + // archive's receipt, and it is in the audit log on purpose: "was yesterday + // archived, and is the object in the bucket the object we wrote" is then + // answerable from the trail rather than only from the bucket, which is the + // thing the archive exists to stop being the single copy. Its actor is the + // system — a scheduled export is nobody's request. + EventAuditArchiveExported EventType = "saas.audit.archive_exported" // EventHopAdmissionKeyPublished records that Keys began publishing a hop // admission key, the key every module behind the hop trusts to have // admitted a call. Its actor is the system: the hop proved itself by its @@ -747,6 +755,16 @@ var auditEventCatalog = []AuditEventDefinition{ // no pod is refused before anything is published. PayloadField{Name: "instance", Kind: FieldString, Required: true}, PayloadField{Name: "expires_at", Kind: FieldString, Required: true}), + mutation(EventAuditArchiveExported, CategorySecurity, "A day of the audit log was written to the configured archive.", + PayloadField{Name: "day", Kind: FieldString, Required: true}, + PayloadField{Name: "object_key", Kind: FieldString, Required: true}, + PayloadField{Name: "digest", Kind: FieldString, Required: true}, + PayloadField{Name: "bytes", Kind: FieldInt, Required: true}, + PayloadField{Name: "events", Kind: FieldInt, Required: true}, + // The store's own version identifier for the object it wrote. Optional: + // a backend that reports none is not a failed archive, and demanding it + // would make the receipt depend on which store the composition chose. + PayloadField{Name: "etag", Kind: FieldString}), mutation(EventModuleEnvelopeDelivered, CategorySecurity, "A release-signed revision of the module envelope was delivered.", PayloadField{Name: "revision", Kind: FieldInt, Required: true}, PayloadField{Name: "previous_revision", Kind: FieldInt, Required: true}, diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index eacaee6b3..04cd234f5 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -42,6 +42,7 @@ type Service struct { resolver auth.IdentityResolver minter auth.JWTMinter providerSignOut ProviderSignOut // what the identity provider offers for ending its own session + auditArchive AuditArchive // where the daily audit archive is written, when one is configured emailOutbox *email.Outbox // optional durable email producer; transport is worker-only billing BillingClient // optional: Stripe client for checkout/portal billingURLs BillingRedirects // server-owned Stripe return destinations diff --git a/module/services/accounts/code/pkg/datasource/objectstore/archive.go b/module/services/accounts/code/pkg/datasource/objectstore/archive.go new file mode 100644 index 000000000..20f598d20 --- /dev/null +++ b/module/services/accounts/code/pkg/datasource/objectstore/archive.go @@ -0,0 +1,113 @@ +package objectstore + +import ( + "context" + "errors" + "fmt" + "time" + + storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The host's own writes to the composed object-storage service. +// +// Every other call in this package carries a per-call Binding: a tenant's +// bucket and the credential for it, for one source, read-only. These do not. +// `PutRequest` carries no Binding field at all, so a Put always lands in the +// object-storage service's own configured bucket under its own prefix — the +// bucket the composition points that service at, not one accounts names. That +// is exactly right for a host-owned archive: the key is accounts' to choose, +// while the bucket, its credentials and its retention posture stay with the +// composition and with whoever operates it. +// +// Nothing here reads. The host writes an archive object and never needs it +// back; reading it is an auditor's job, through the store, with the store's own +// access control. + +// ErrObjectExists reports a create-if-absent write the store refused because +// the key is already there. It is a successful outcome for an idempotent +// archiver — the day is already archived — and a refusal for anything that +// meant to overwrite, so it is a named error rather than a silent success. +var ErrObjectExists = errors.New("objectstore: the object already exists") + +// putChunk bounds one streamed data frame. The service's own limit is larger; +// this keeps a single gRPC message modest for a multi-megabyte archive. +const putChunk = 1 << 20 + +// writeTimeout bounds one archive write. A day's audit log is a bounded body +// and the archiver holds a job lease while this runs, so a store that accepts +// the stream and then stalls must not hold the lease indefinitely. +const writeTimeout = 2 * time.Minute + +// HostStore writes objects to the object-storage service's own bucket. +type HostStore struct { + client storagev0.ObjectStorageClient +} + +// NewHostStore binds the host's writes to client. +func NewHostStore(client storagev0.ObjectStorageClient) *HostStore { + return &HostStore{client: client} +} + +// PutIfAbsent writes body at key and refuses, with ErrObjectExists, if the key +// is already taken. The refusal is the store's own (`if_none_match: "*"`), +// decided where the object is, not by a read-then-write here that two runs +// could interleave inside. +// +// It is the write an archive wants: an object that was put once can never be +// quietly replaced by a later run with different bytes, whatever the bucket's +// retention posture is — which, until the lock exists, is the only thing +// standing between an archived day and a rewritten one. +func (s *HostStore) PutIfAbsent(ctx context.Context, key, contentType string, body []byte) (etag string, err error) { + if s == nil || s.client == nil { + return "", errors.New("objectstore: no object-storage service is composed") + } + if key == "" { + return "", errors.New("objectstore: a key is required") + } + ctx, cancel := context.WithTimeout(ctx, writeTimeout) + defer cancel() + stream, err := s.client.Put(ctx) + if err != nil { + return "", putError(err) + } + header := &storagev0.PutRequest{Kind: &storagev0.PutRequest_Header{Header: &storagev0.PutHeader{ + Key: key, + ContentType: contentType, + IfNoneMatch: "*", + TotalSize: int64(len(body)), + CacheControl: "no-store", + }}} + // A Send that fails means the server already closed the stream with its + // real status, and that status — "the object exists", "the bucket refuses + // this key" — is on CloseAndRecv, not on Send, which reports only io.EOF. + // So a send failure stops the loop and the answer comes from CloseAndRecv + // either way; returning the Send error would turn every store refusal into + // an unexplained EOF. + sent := stream.Send(header) == nil + for offset := 0; sent && offset < len(body); offset += putChunk { + end := min(offset+putChunk, len(body)) + sent = stream.Send(&storagev0.PutRequest{ + Kind: &storagev0.PutRequest_Data{Data: body[offset:end]}, + }) == nil + } + result, err := stream.CloseAndRecv() + if err != nil { + return "", putError(err) + } + return result.GetEtag(), nil +} + +// putError maps the store's refusals. A create-if-absent collision is +// ErrObjectExists whichever code the backend reports it under — S3 answers 412 +// and GCS 409, and the service forwards the condition rather than normalising +// it — so every code that can carry one is recognised. +func putError(err error) error { + switch status.Code(err) { + case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted: + return fmt.Errorf("%w: %w", ErrObjectExists, err) + } + return storeError(err) +} diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 5f93daabe..c6d8b3f37 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -372,6 +372,35 @@ func doWork(ctx context.Context) (clean Clean, err error) { wool.Get(ctx).Warn("the composed object-storage service cannot read a tenant's bucket; object-storage datasources are unavailable", wool.ErrField(err)) } } + // The daily audit archive writes to the same composed object-storage + // service, but unbound: its Put carries no bucket, so it lands in that + // service's own configured bucket — the host's, not a tenant's. The prefix + // is accounts' to name and the bucket is the composition's, which is why + // AUDIT_ARCHIVE_URI is refused if it names one. + auditArchivePrefix, err := business.ParseAuditArchiveURI(workspaceEnv("audit-archive", "AUDIT_ARCHIVE_URI")) + if err != nil { + return nil, err + } + switch { + case auditArchivePrefix == "": + // Say it at boot. "Is the audit log archived off this database?" was + // otherwise answerable only by reading configuration on the box, and the + // answer that matters — no — looked exactly like a host that had one. + w.Info("no audit archive is configured (audit-archive/AUDIT_ARCHIVE_URI); the audit log's only copy is this database") + case objectStorage == nil: + // A configured destination with nothing to write it through is a + // misconfiguration, not a degraded mode: the operator asked for an + // archive and would otherwise get silence. + return nil, fmt.Errorf( + "audit-archive/AUDIT_ARCHIVE_URI is set to %q but no object-storage service is composed, so no day could ever be archived; compose one or clear the key", + auditArchivePrefix) + default: + service.SetAuditArchive(business.AuditArchive{ + Prefix: auditArchivePrefix, + Store: objectstore.NewHostStore(storagev0.NewObjectStorageClient(objectStorage)), + }) + w.Info("daily audit archive enabled", wool.Field("prefix", auditArchivePrefix)) + } // Every provider credential's operations are metered in one window shared by // every replica, so a sync a person starts is served before background work. service.SetDatasourceBudgetStore(store) @@ -1118,6 +1147,21 @@ func doWork(ctx context.Context) (clean Clean, err error) { return nil, err } + // The daily audit archive (issue #1296): a leased worker that writes one + // complete UTC day of the whole platform's audit log to the configured + // archive. Like the privacy worker it runs whether or not a destination is + // wired, so a day enqueued by a runtime that had one dead-letters visibly + // here instead of sitting pending after the key was cleared. + auditArchiveWorker, err := jobs.NewWorker(jobs.WorkerConfig{ + Store: jobStore, + Queue: business.AuditArchiveQueue, + Handler: service.NewAuditArchiveJobHandler(), + RetryDelay: business.AuditArchiveRetryDelay, + }) + if err != nil { + return nil, err + } + // The change-set compiler (issue #487): a leased worker that turns each raw // GitHub delivery — and each periodic/forced reconcile request — into a set of // per-file ingest ops, holding the source's decrypted token so all GitHub @@ -1278,12 +1322,30 @@ func doWork(ctx context.Context) (clean Clean, err error) { } } + // The daily audit archive (issue #1296) offers every complete day in its + // catch-up window; the enqueue is deduplicated by day, so a tick that + // offers a day already archived costs one refused insert and nothing + // else. The export itself runs in the leased worker, off this tick: an + // object store that is slow or down must not hold the retention pass + // that shares it. + sweepAuditArchive := func() { + if !service.AuditArchiveConfigured() { + return + } + if n, err := service.ScheduleAuditArchive(retentionCtx, time.Now()); err != nil { + rw.Warn("audit archive scheduling failed", wool.ErrField(err)) + } else if n > 0 { + rw.Info("offered audit archive days", wool.Field("count", n)) + } + } + // Run once immediately on startup. runRetention() sweepReplay() sweepPrivacyArtifacts() sweepReconcile() sweepInstallationRecheck() + sweepAuditArchive() retentionTicker := time.NewTicker(24 * time.Hour) replayTicker := time.NewTicker(time.Hour) @@ -1297,6 +1359,7 @@ func doWork(ctx context.Context) (clean Clean, err error) { return case <-retentionTicker.C: runRetention() + sweepAuditArchive() case <-replayTicker.C: sweepReplay() sweepPrivacyArtifacts() @@ -1345,6 +1408,7 @@ func doWork(ctx context.Context) (clean Clean, err error) { webhookWorker.Start(ctx) datasourceSyncWorker.Start(ctx) privacyWorker.Start(ctx) + auditArchiveWorker.Start(ctx) datasourceDeliveryWorker.Start(ctx) datasourceInstallationWorker.Start(ctx) eventRelayWorker.Start(ctx) diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 499d607a6..b0d7052c3 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -37,6 +37,9 @@ workspace-configuration-dependencies: - abuse-protection - application - artifact-cache + # The daily audit archive's destination prefix. Unset means no archive is + # configured and none is scheduled (configurations/local/audit-archive.env). + - audit-archive - billing - datasource-keys - datasource-oauth From 3345d36c08755443e6de68495ea08ccfffef8702 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:53:05 -0500 Subject: [PATCH 05/12] fix(accounts): a day's archive window stops one instant before the next midnight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store's audit window is inclusive at both ends (`created_at >= From AND created_at <= To`), so asking for the next midnight as the end put an event written at exactly 00:00:00.000000 into two consecutive days' archives — a duplicated row in a trail whose whole value is being exact. Postgres timestamps are microsecond-resolution, so stepping back one microsecond is exact rather than a fudge. Co-Authored-By: Claude Opus 5 --- .../code/pkg/business/audit_archive.go | 8 ++++- .../code/pkg/business/audit_archive_test.go | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/module/services/accounts/code/pkg/business/audit_archive.go b/module/services/accounts/code/pkg/business/audit_archive.go index 5117a8590..9fbbc051e 100644 --- a/module/services/accounts/code/pkg/business/audit_archive.go +++ b/module/services/accounts/code/pkg/business/audit_archive.go @@ -368,11 +368,17 @@ func (s *Service) auditDayExport(ctx context.Context, day string) ([]byte, int, return nil, 0, jobs.NewProcessingError("audit_archive.invalid_day", "not a day", false) } from = from.UTC() + // The store's window is inclusive at BOTH ends (`created_at >= From AND + // created_at <= To`), so the day's end is the last instant inside it and + // not the next midnight: an event written at exactly 00:00:00.000000 would + // otherwise land in two consecutive days' archives. Postgres timestamps are + // microsecond-resolution, so stepping back one microsecond is exact rather + // than a fudge. + until := from.AddDate(0, 0, 1).Add(-time.Microsecond) resolver := s.AuditEventResolver() entries := make([]auditExportEntry, 0, 256) pageToken := "" for { - until := from.AddDate(0, 0, 1) page, next, _, err := s.QueryAuditLog(ctx, AuditQuery{ From: &from, To: &until, diff --git a/module/services/accounts/code/pkg/business/audit_archive_test.go b/module/services/accounts/code/pkg/business/audit_archive_test.go index f3bd54757..ff135f614 100644 --- a/module/services/accounts/code/pkg/business/audit_archive_test.go +++ b/module/services/accounts/code/pkg/business/audit_archive_test.go @@ -274,3 +274,39 @@ func (e *recordingAuditEmitter) EmitTx(_ context.Context, entry AuditEntry) erro e.entries = append(e.entries, entry) return nil } + +// The store's audit window is inclusive at both ends, so a day's export must +// stop one instant before the next midnight. Without that, an event written at +// exactly 00:00:00.000000 lands in two consecutive days' archives — a +// duplicated row in a trail whose whole value is that it is exact. +func TestADaysWindowDoesNotOverlapTheNext(t *testing.T) { + store := &windowRecordingStore{} + svc := &Service{ + auditArchive: AuditArchive{Prefix: "audit/archive", Store: &scriptedArchiveStore{}}, + store: store, + } + + _, _, err := svc.auditDayExport(context.Background(), "2026-11-02") + require.NoError(t, err) + require.NotNil(t, store.from) + require.NotNil(t, store.to) + require.Equal(t, time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC), store.from.UTC()) + require.Equal(t, time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC).Add(-time.Microsecond), store.to.UTC()) + require.True(t, store.to.Before(time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC)), + "the next day's first instant belongs to the next day's archive") +} + +// windowRecordingStore captures the window the export asked for. +type windowRecordingStore struct { + Store + from, to *time.Time +} + +func (s *windowRecordingStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *windowRecordingStore) QueryAuditLog(_ context.Context, q AuditQuery) ([]AuditEntry, string, int32, error) { + s.from, s.to = q.From, q.To + return nil, "", 0, nil +} From fb4cb538acde71e0d085734fa28dc8664dc34b18 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:55:42 -0500 Subject: [PATCH 06/12] docs(configurations): the audit-archive group, in the group reference Every other group a service declares has a section in module/configurations/README.md; the new one had its semantics only in its own env file, which is the copy an operator reads last. Co-Authored-By: Claude Opus 5 --- module/configurations/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/module/configurations/README.md b/module/configurations/README.md index c497751ac..e3af71bb2 100644 --- a/module/configurations/README.md +++ b/module/configurations/README.md @@ -88,6 +88,24 @@ that endpoint is loopback, cluster-internal (`*.svc`, `*.cluster.local`, a single-label name) or plain http: it logs what it found and that it expected this key, and refuses rather than presenting an origin accounts will refuse. +## audit-archive + +Where the daily audit archive is written. Not a credential group: the bucket's +credentials belong to the composed object-storage service, which is also the +reason this group names no bucket. + +| Key | Empty means | Set to | +| --- | --- | --- | +| `AUDIT_ARCHIVE_URI` | no archive: nothing is scheduled, no worker runs, and the audit log's only copy is the database — which the boot says out loud | a key **prefix** inside the composed object-storage service's own bucket (`audit/archive`). A value naming a bucket, a host or a scheme (`s3://…`) fails the boot rather than writing somewhere the operator did not choose; so does a prefix with no object-storage service composed | + +Each complete UTC day becomes one object under that prefix, with a receipt in +the audit log carrying the object key, its digest and the byte and event counts. +The write is create-if-absent, so an archived day's bytes cannot be replaced by +a later run — which makes the archive tamper-**evident**, not immutable. The +object-lock policy that would make it immutable belongs to the infrastructure +that provisions the bucket; [../../AUTHZ.md](../../AUTHZ.md) states exactly what +is and is not proven. + ## security Session, MFA and WebAuthn policy, plus three operator switches whose empty value From 6bcc4e082179ddc6292c900a356e31046311a1ee Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:57:18 -0500 Subject: [PATCH 07/12] test(accounts): record the archive's and the back-channel's control-plane crossings Both new call sites cross scopes deliberately and for the same reason: neither path has a request principal to scope to. The archive exports every organisation's audit log from a scheduled job, and a back-channel logout acts on a provider subject that is nobody's caller, so RLS would otherwise scope `sessions` and `user_identities` to an identity that does not exist on that request. The inventory exists so each crossing is countable rather than merely greppable; these two are now in it. Co-Authored-By: Claude Opus 5 --- .../code/pkg/infra/testdata/control_plane_call_sites.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt index 2f99cb2d7..f4334a218 100644 --- a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt +++ b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt @@ -29,6 +29,7 @@ pkg/auth/pg/session_store.go: (*SessionStore).RotateRefresh: WithControlPlane pkg/business/audit.go: (*DurableAuditEmitter).Emit: WithControlPlane pkg/business/audit.go: (*Service).AggregateAuditLog: WithControlPlane pkg/business/audit.go: (*Service).QueryAuditLog: WithControlPlane +pkg/business/audit_archive.go: (*Service).archiveAuditDay: WithControlPlane pkg/business/authority_core_delivery.go: (*Service).composeAppliedDelivery: WithControlPlane pkg/business/authority_delivery.go: (*Service).verifyAppliedAuthorityDocument: WithControlPlane pkg/business/canonical_solution_reads.go: (*Service).canonicalSolutionEntitlements: WithControlPlane @@ -128,6 +129,7 @@ pkg/business/privacy_jobs.go: (*Service).parkUnexecutablePrivacyJob: WithControl pkg/business/privacy_jobs.go: (*Service).runPrivacyWorkflow: WithControlPlane pkg/business/privacy_jobs.go: (*Service).runPrivacyWorkflow: WithControlPlane pkg/business/privacy_jobs.go: (*Service).runPrivacyWorkflow: WithControlPlane +pkg/business/provider_signout.go: (*Service).BackchannelLogout: WithControlPlane pkg/business/retention.go: (*Service).PurgeExpiredWorkContextReplays: WithControlPlane pkg/business/retention.go: (*Service).RunRetention: WithControlPlane pkg/business/retention.go: (*Service).RunRetention: WithControlPlane From 7a44c989cdfbcbf28bf046df08ce6623fd2745a0 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:59:24 -0500 Subject: [PATCH 08/12] test(accounts): the business suite's logout case reads the new response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Service.Logout` answers a `LogoutResponse` now, so its one caller outside the pure build tag had to move. The fixture configures no identity-provider sign-out, so the case also asserts the empty end-session URL — the whole answer a client is meant to read as "nothing further to do". Co-Authored-By: Claude Opus 5 --- module/services/accounts/code/pkg/business/service_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index fdc71c0e3..c83f81386 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -539,8 +539,11 @@ func TestLogout(t *testing.T) { // No access token and no verified session: the body's refresh credential alone // must still end the family, which is what a non-browser caller presents. - err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") + logoutResp, err := testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") require.NoError(t, err) + // This fixture configures no identity provider sign-out, so there is nothing + // for the client to complete. An empty URL is the whole answer. + require.Empty(t, logoutResp.GetEndSessionUrl()) _, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{ RefreshToken: authResp.RefreshToken, From 34452bb212394bb9ffe909c40f56178c553f2584 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:01:19 -0500 Subject: [PATCH 09/12] test(gateway): route the back-channel logout receiver, and derive the extension count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit REST extension inventory gains POST /v1/auth/backchannel-logout: routed, unprotected (the provider's signed logout token is the credential, and it holds no session at this perimeter), and asserted not to shadow the transcoded /v1/auth/logout beside it — RegisterHTTPRoute matches by prefix, so that adjacency is the thing worth a test. The count assertion now derives from the inventory map instead of naming 15. The map is already exhaustive — the loop consumes each entry and the emptiness check refuses an unnamed one — so the literal added nothing except the one failure mode a literal has: two branches each adding a route and each bumping the same figure merge into a count that is silently wrong with every test green. Co-Authored-By: Claude Opus 5 --- .../auth-gateway/code/routing_rest_test.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/module/services/auth-gateway/code/routing_rest_test.go b/module/services/auth-gateway/code/routing_rest_test.go index d23346c48..151f68608 100644 --- a/module/services/auth-gateway/code/routing_rest_test.go +++ b/module/services/auth-gateway/code/routing_rest_test.go @@ -49,12 +49,17 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { require.Nil(t, paths["POST /v1/permissions:check"]) require.False(t, paths["POST /v1/users"].Protected) - extensions, err := LoadRESTExtensionsFromDir(context.Background(), DefaultRoutingDir()) - require.NoError(t, err) - require.Len(t, extensions, 15) + // Every explicit extension, and whether the full ext_authz bearer path runs + // for it. The loop below consumes each entry and the emptiness check after + // it refuses one this map does not name, so the map IS the inventory — + // which is why the length assertion derives from it rather than naming a + // number. A pinned literal is the shape where two branches each add a route + // and each bump the same figure, and the merge of both is a count that is + // silently wrong while every test is green. wantExtensions := map[string]bool{ "POST /v1/auth/magic-link": false, "POST /v1/auth/magic-link/verify": false, + "POST /v1/auth/backchannel-logout": false, "POST /v1/billing/webhook": false, "POST /v1/email/webhook/resend": false, "POST /v1/datasource/github/webhook/{source_id}": false, @@ -69,6 +74,10 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { "POST /v1/oauth2/token": false, "POST /v1/oauth2/authorize/grant": true, } + + extensions, err := LoadRESTExtensionsFromDir(context.Background(), DefaultRoutingDir()) + require.NoError(t, err) + require.Len(t, extensions, len(wantExtensions)) for _, entry := range extensions { require.Empty(t, entry.Procedure) // The upstream key is derived from the folder's {module, service} @@ -96,6 +105,15 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { require.Nil(t, matcher.MatchREST(http.MethodPost, "/v1/permissions:check")) require.Equal(t, "/v1/status", matcher.MatchREST(http.MethodGet, "/v1/status").Path) require.False(t, matcher.MatchREST(http.MethodGet, "/v1/status").Protected) + // The back-channel logout receiver is routed, unprotected (the provider's + // signed logout token is the credential, and it holds no session here), and + // does not shadow the transcoded `/v1/auth/logout` beside it. + require.Equal(t, "/v1/auth/backchannel-logout", + matcher.MatchREST(http.MethodPost, "/v1/auth/backchannel-logout").Path) + require.False(t, matcher.MatchREST(http.MethodPost, "/v1/auth/backchannel-logout").Protected) + require.NotNil(t, matcher.MatchREST(http.MethodPost, "/v1/auth/logout")) + require.Empty(t, matcher.MatchREST(http.MethodPost, "/v1/auth/backchannel-logout").Procedure) + require.NotEmpty(t, matcher.MatchREST(http.MethodPost, "/v1/auth/logout").Procedure) } func TestRESTExtensionsRejectDescriptorRoutesAndDisabledEntries(t *testing.T) { From 9ac18dcaa0888e445a2e9947c85e067cc68524e7 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:04:35 -0500 Subject: [PATCH 10/12] chore(frontend): the workspace lock carries saas-sdk 0.3.43 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `module-verify`'s workspace-sync check compares each `packages/` entry in the frontend `package-lock.json` against that workspace's own `package.json`, so bumping the SDK's version without regenerating the lock leaves the one field stale and reds "Base manifest integrity" — a job name that reads like a manifest hash and sends you to the wrong tool. Regenerated with `npm install --package-lock-only`; the lock is generated and never hand-edited. Co-Authored-By: Claude Opus 5 --- module/services/frontend/code/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index f5679667a..88a28fbde 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15742,7 +15742,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.42", + "version": "0.3.43", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", From ad421cbe278126ecbb670e440f05f8bc4d7b158d Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:23:23 -0500 Subject: [PATCH 11/12] fix(accounts): the sign-out URL is an additive RPC, not a changed Logout response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's proto breaking-change gate refused the previous shape, and it was right: RPC "Logout" on service "AuthService" changed response type from "google.protobuf.Empty" to "saas.accounts.v1.LogoutResponse" module/CONTRACT_VERSIONING.md is explicit — a published v1 package is additive and an RPC's response type is never changed; a change that cannot be represented additively needs a new package major. Every client generated against `Logout` returning Empty would have had to be rebuilt. AGENTS.md says a CI gate is part of the definition of done and is never allowlisted past, so the design moved rather than the gate. `Logout` goes back to `google.protobuf.Empty` and `LogoutRequest` loses the field it briefly gained. The URL now has its own public RPC, `GetProviderSignOut` (`POST /v1/auth/provider-sign-out`), carrying the optional client-supplied `id_token_hint` and answering `end_session_url`. The separate call is better, not merely permitted. A client can hold the URL *before* it signs out, so it has somewhere to send the person the moment the session ends — and it is not presenting a bearer that the very call it is making has just revoked. Public for the same reason GetJWKS is: the client id and the registered post-logout redirect it echoes are already public, and asking for it after a sign-out means there is no session left to authenticate with. `buf breaking` against origin/main now passes locally, run the way ci.yml runs it. The gateway's public-route count moves 18 → 19; it stays a literal because there is no exhaustive inventory to derive it from, but the one route that moved it is now asserted by name immediately after, so the figure is anchored to a claim instead of floating. Co-Authored-By: Claude Opus 5 --- .../source-read-sdk/library.codefly.yaml | 2 +- module/FEATURES.md | 18 +- module/REST_SURFACE.md | 6 +- .../api/accounts/authority/contract.binpb | Bin 1313690 -> 1315199 bytes .../saas/accounts/v1/authentication.proto | 51 +++- .../api/accounts/connect/contract.binpb | Bin 1313690 -> 1315199 bytes .../saas/accounts/v1/authentication.proto | 51 +++- module/contracts/api/catalog.codefly.json | 5 +- module/module.package.codefly.yaml | 4 +- module/services/accounts/AUTHZ_MATRIX.md | 5 +- .../code/pkg/adapters/connect_handlers.go | 5 +- .../rest_refresh_cookie_chain_test.go | 7 +- .../adapters/rest_registration_catalog_gen.go | 1 + .../accounts/code/pkg/adapters/rpcs.go | 14 +- .../accounts/code/pkg/business/auth.go | 35 ++- .../code/pkg/business/introspection.go | 1 + .../pkg/business/provider_signout_test.go | 36 +++ .../code/pkg/business/service_test.go | 6 +- .../authentication.connect.go | 65 +++- .../gen/saas/accounts/v1/authentication.pb.go | 278 ++++++++++-------- .../saas/accounts/v1/authentication.pb.gw.go | 66 +++++ .../accounts/v1/authentication_grpc.pb.go | 72 ++++- .../generated/api-contract-surfaces.json | 1 + .../accounts/generated/authz-methods.json | 39 ++- .../accounts/generated/gateway-routes.json | 48 +++ .../generated/openapi-raw/api.swagger.json | 72 ++++- .../accounts/generated/rest-surface.json | 16 + .../accounts/generated/service-catalog.json | 54 +++- .../accounts/openapi/api.swagger.json | 72 ++++- .../saas/accounts/v1/authentication.proto | 51 +++- .../auth-gateway/code/authz_catalog_gen.go | 2 + .../auth-gateway/code/routing_catalog_gen.go | 2 + .../code/routing_rest_catalog_gen.go | 1 + .../auth-gateway/code/routing_rest_test.go | 13 +- .../saas-sdk/generated/library.codefly.yaml | 2 +- .../gen/saas/accounts/v1/authentication_pb.ts | 98 ++++-- .../gen/saas/accounts/v1/authentication_pb.ts | 98 ++++-- 37 files changed, 996 insertions(+), 301 deletions(-) diff --git a/libraries/source-read-sdk/library.codefly.yaml b/libraries/source-read-sdk/library.codefly.yaml index 8e4b5e27f..f06a2e31d 100644 --- a/libraries/source-read-sdk/library.codefly.yaml +++ b/libraries/source-read-sdk/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: authority - contract-digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 + contract-digest: sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade services: - ModuleCapabilitiesService generated-by: diff --git a/module/FEATURES.md b/module/FEATURES.md index c9a1d23ec..672cfafbe 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -222,14 +222,26 @@ is silent — the person presses "sign out", presses "sign in", and is back in without being asked anything. Closing that is two independent halves, and a provider offers each on its own, so each is wired on its own evidence. -**RP-initiated logout (the host asks the client to finish).** `Logout` returns +**RP-initiated logout (the host asks the client to finish).** +`GetProviderSignOut` (`POST /v1/auth/provider-sign-out`) returns `end_session_url`: the provider's `end_session_endpoint` with `post_logout_redirect_uri`, and with the client's `id_token_hint` when it supplied one or the host's `client_id` when it did not (OpenID Connect RP-Initiated Logout 1.0 §2 allows that substitution precisely for the case where -no hint is available). The client sends the person's browser there after the +no hint is available). The client sends the person's browser there once the host session has ended. +It is a **separate public call**, not a field on the `Logout` response, for two +reasons. `saas.accounts.v1` is additive: an RPC's response type is never changed +([CONTRACT_VERSIONING.md](./CONTRACT_VERSIONING.md)), and `Logout` has always +answered `google.protobuf.Empty`, so every client generated against it would +have to be rebuilt. And asking separately sequences better — a client can hold +the URL *before* it signs out, so it has somewhere to send the person the moment +the session ends, and it is not presenting a bearer that the very call it is +making has just revoked. Public for the same reason `GetJWKS` is: everything it +answers with — the deployment's client id, its registered post-logout redirect — +is already public. + The hint is **client-supplied** and the host stores no provider `id_token`: it has no other use for one, and retaining a provider credential to hand back later is a liability rather than a feature. The redirect is **never** taken from the @@ -363,7 +375,7 @@ their next owning change. | Session list + revoke | ✅ | Stable per-device family ids, device context, idle/absolute expiry, whole-family revoke | | Session lifetime policy | ✅ | Configurable fixed absolute TTL, idle TTL, and serialized active-device cap | | Logout | ✅ | Revokes the presented device family, the presented access token and the verified session's access half; the browser's cookie is the credential carrier, so an empty body is a complete sign-out | -| RP-initiated logout | ✅ | `Logout` returns the provider's `end_session_endpoint` with `id_token_hint`/`client_id` and the deployment's registered `post_logout_redirect_uri`; empty when the provider publishes none | +| RP-initiated logout | ✅ | `GetProviderSignOut` returns the provider's `end_session_endpoint` with `id_token_hint`/`client_id` and the deployment's registered `post_logout_redirect_uri`; empty when the provider publishes none. A separate additive call rather than a field on `Logout`, whose response type `saas.accounts.v1` may not change | | Back-channel logout | ✅ | `POST /v1/auth/backchannel-logout` verifies the provider's logout token against its JWKS and revokes every host session of the named subject, access half included. Mounted only when the provider can verify one. A `sid`-only token answers 501: provider session ids are not recorded | | OAuth state / CSRF | ✅ | Server-signed state bound to (provider, redirect_uri), single-use: the nonce is consumed on first verification and the state is refused when single use cannot be recorded | | OAuth PKCE | ❌ | Comments mention PKCE but exchanger uses `client_secret` (acceptable for confidential server-side; PKCE adds defense for SPA-driven flows) | diff --git a/module/REST_SURFACE.md b/module/REST_SURFACE.md index 90e6b4884..c6340d062 100644 --- a/module/REST_SURFACE.md +++ b/module/REST_SURFACE.md @@ -17,11 +17,11 @@ module ceiling. The complete regeneration includes these additions and the calla ## Current surface -The accounts projection contains 175 descriptor routes across 30 services, +The accounts projection contains 176 descriptor routes across 30 services, counted from `generated/rest-surface.json`: -- 18 public routes and 157 authenticated routes; -- 148 OpenAPI paths and 175 operations; +- 19 public routes and 157 authenticated routes; +- 149 OpenAPI paths and 176 operations; - zero internal RPCs; - the explicit non-protobuf extensions loaded by auth-gateway: magic-link request/verification, the billing and Resend email webhooks, the two GitHub diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 559d627f42b72bdf85139b11e60276b19e43616e..86efa58a36c8f6dad6118990ab4c46615e344162 100644 GIT binary patch delta 9125 zcmZvhYjjoBwa2s9TG=^!p9IKGAR!63#abUtQfVC>w+^jRM5Q{!2CUA~HB&sRH-BNaGt)aYJF|OAU$HIo`@T$}7>x6)vYqD@^A}{g&+E!|7wetS zInrxOXFI2nb{eE{C;JWGbuf5D;7xYLD>Z|?_1@}or?1pH);qd%$+=barT-r59XskS z#}B?csjpa?&@;UDU+Kf^sv4WVKfWzf=*f2%GUHAt{m)o$OzG*+8$PVZWYSVVDlLPz zkM;HozjTx1R|Mr{WqV51i-%1Z+;yn;hdO7V)bn$1)?qg&U#Q`Md%sh*)^QrHRN?n7 zRr+S%uu;+3ZD)kVuJ&S>>FzAxzf4%j6bjjVcL)up9|B=_FpYVo3;4956jcjiJ|t^=2wnduCV zoR#nF%VmB%amty~+9ph#cHVC$wN0Ho<;=-vp4vLA^QdNc>I`YdMPWxSiF{CON zLI7(H;U^1jUF`+@J1a9AfH{By%EdE#GQ9-@c@_pBE#lpkM=o(8sj{uUpZ291?gZ+7 zCv~V_<(I4BqO3d!)EY;m{PoILB)}Zp_j7N8I35NDuW`Il@nA{j=m!6Of!-Psxa*y= z=g?cr#P@ou)OEafT$PWMy4rIyTEl5wnbOMRy+$`%EM#&sN?VTi?m2P<+^=_1SdNHg z1jV8@QYgD6LV?ZZn1=18dr$CgRe6vc{OvEiVh_y!v=5BHQRRHu;qZtU7RC zaLQhE98S0zBVTY*wWa@>;59YZ@q}ugzsd1}*u?xz&d5>zlayHyxLcgE*9J$PrSeHUDt3{JLtl59i#<2P%vv63Snv5FTL5#P%vSK;|+@^;Nm-+>cGFM0>o+q z_cN#L65%ww>x88-HD1GD^+fM?GOER_&*Bo-@@4kWWrpD@FasCXKBt_W!p7)5aaCaA z8hfZJ!_glwQ#8D8pQHT1Pq6@iq}A_pB&SV-G}O->HO#M$0NQBq^@XE`4;|(PYOkYy zU@lJV_b0JAfeY(^jm<%9`(yaEg5d8b_&PSg$MA*&Ha5l|oDQ+^fDJCL$(I})$Ho$T zedQ?KHMK$@kb@M+xeQ4JF08qtwC+@|EyB+TiQxLhVe@QoQU}*BelX7l#~OnBKS32_NG!qC#kiw!FOLk| z%S74bLND==xaegfb##`mI#QG&==nm>ZlDxQwtf@;h{5y4n4@D8Zs&`Z*4V@%nlFBQ zwm$+LhN*~eG%3eS^WSpN=%WJnYEgDgDg2f9dNc~It`?~u@!V0|+zW^eT^44bVCmHf z3u6}uuQncw%PtUg^_)#z;4TzpC6i4ZMlOuK)^Re6;$&b33Kl05DVP|%D9#2Z+%Af< zfr)Gu#o3U_*({=LXrrtT-0MWyjpn-baCMzXHFJdZoXrx-W(idqcA#Je4Ockflc~*dlORuITC3G->ZndaeYtWIv&9qw7uQfF^8cO(ZTtbo}UBgFW znr0Z#tr55@QD--%5X_@Otb-@Y6gGl+RHXD`g9)dP33UwbS7grfFn|>vFC4gg93^0= zjnoCOe!|XX!eqO#MLXyAd|Vte-k1V&pAf3i%*CZ(>7BBXZ$nRrrlaDqpz?%hZt)LL z#hU{68Bz8eD*h2FzN^k_ES=NlZH}67y=O$~FrL)J;5Wn-hZ!hXx~8x&c0;^$VB!KB zXz5U1O}y@&O{QWjtj739Hchi*ET%q7&2oyKOG{;Ayd^bZQzJ`nNw6??Bi4g)MYq?D zRPD*Wa8Zm+0&iQNPW4W!*(6dm{_WTt-7;#K%|g6WI%Ar5TeMlYygQH%-HyW96 zwpp~CNUcMr3fxzOdPBJMK*Pd=SK^NPib%hK?!$75HN^C)a1R+O!SmmRd-zZZ<=P_L z=n#1G@9peWp9he}?cUK8&4p^_h{e+c)dL!~sH8HL+ARH_Wrb{dsr z6zve|ZKPCV!e8~w!{Y*Xhe*GToZ`qxRDpU^xZ}R1RRroS;SQ(fM+VqUcM5SpH$U0n zuoGQLwILI^?G!aXj7`wpDaJ>WOfU}cz9-aPi`P+%_dSu`Yw@xMx9=x-S%T{i61*${ z?}s$1#!u75$ytSip4}zzI+chKR5;u%QsYf(dMG@8EK)}rc&woipC*N135ED9DFjO> z#2(?c44sHVe4Z47N1+g3a3TIfvqAFvg!;-B!cn>q`$YOHTL>+p5c`FDn2nYOq7YwF zAs*8Wj@!u`DHfGBoZ~$j&5jx?CmgGGeJJP=A!} zQ9~dtkZK_y=_Mmfr54EaLQsl>HB06idAKc=9*I(2D^aRjbg8g`Trb7#rIvHOo1^Oq zs$MM;nO-kLepN-~=+XRod3w%7AyKNuQr&7xC3UG5%k-_bRIGvQlB85D!SfACsaQg( zZsbzw@L8k1=+DybMXZ7KO$lq3!1^ySYs6_#-<&{X38-&LP_qQoOKFteizuULnN+t~ zu+o5CCeycBuv)|tSWYF0qcuxl1##7T5xfmZ@wnt0G59HP7?9Y$G+l+<2IOI<#3twt z$X`dZOfdLV;NB_KBNngHc<+?yM=V~};P$QrFH3N}GQrCd@UEg!dLgjH3*nw*A&_YH zqI;8-V1~lwebn_jUoAqrKUoQ^p*n*}byz}m9!#pk5*ET=lZC(%s`C)46PZnmHI!#{ zQbd-p5*|)g0zq0M)nm3`$`ovkOh0A|rbQHNt#q4h0;XV(CIu@G+;x!@FWZWh1MNC# z?;hpIcb&9%k8)IOog5$EJjxNvda0hb1uNGDTQAek+k&wMuTLfgV+o$0;)3C|0j**@QStAignK4=hy%)75jyf}oUMkm@Ci zx7^@;L8f1_c(n+;o1{C|Mr-iCNO*Vaea6LCGATYP9d(|!E7~Gm{uYO9U1YUIj^<}f zGQo9=JmDO(5L^VYRjQqqtE;(gmFb<9D{J7oO}alc9mEnmzm_ZnmVosi(k->EtO}y- z(%y3UH{$z7($TDe@0$rdn)y_uQW0!}t?`qF~~hn5_l@_=1uwl~vJ)CV5jI9-Xuu&8t{2!+m>hSI^I6P!?YPnR|T``6Ada1IzVhYWDnNruP_(3_Pm+NIJeXWWglv#t< z`6_-;js$CPeYvtv$}C~MUO~h3-G(JvuU9Jj^u-#M>s87=>9U07`bTBo1X;pzy;|7^ zU6!z17brK1-yvDUdc8*3$1h50p;CWBO8T)jW!CFLmHrcQiX$_RwTqM+KaJ>tSgs{y zpT0aiohq?H#ZO=Km1D7rT>cJ%)$Ad;#cCLTSRfO47pvBIH^ga+yF{sF7O$uAE>Y=a z7B6dXdxOO*Sc2yp6S^#c?w@H?q?2Hc@ZOZ*Wes@$lHg?tcyCsC(9|~@mVoz`1TRa# zyHwft2$n$iR-&uB3mK{9>fh9<))8?eEq#hX>oIRcwOoah{JnyAJwI?)DzSF(lTPpV zPUIuqmCEI>`=I7y@JfZFZ+#6P5Lc=xz2=tU1L7*B9+AU=@vC`*7iNO&|?mH_d=?@R>5za}mEpOn&S zrPkOU^-YhiR_QgiN3{sx4=eWw16OP4(MPC9@s*QqDQP@1D)D#RQr^N>FDLYxO0UlJ zHb!XxS+DG?LK;b~SN2sQ4b1D+ulo$SOV?mm3=;93AA5GXtM;`o067g z3AA5KXs6FcW-lxCs->Mav|m=~S1oNVg7)TQN6;E*ze2Rv>sGD|-0ezyHdysL?<6Ox z1dZ*=<n}Tctm>#I*?GA1k-XMr)q5rMC7)30g2zo1@N|Wp#^L!)(%=7eE=oN4%wkPbVpf-*HPZ#uMetx`RJ?NgZ<#iK-8DER0dxg z8P|pV8yjwEZ~1Yt`Iae-&FwX^ATA9iMU4%U3A-9l-9LpD-)Xs{rTh9gnCQu81dxE7pCnGGT z%j2?K?tm^;{%gh9?qAgfGpfbL(%375srmk4nXZtcEY=%@^urxG)MUf#?)hpkPF@a! zED;O|?N~+T(0te>L#YSE-e!^8Aw<4Y7ATg|zmEy7{Kvwr#VPeow~sDNo>B}8Q(D^E z3&rM!wnAfjTcPDA&4spNTU%pGb76Y(Z7t0WjqUhf-_-Qe(i>xfu1g1C!Og-q3l5Mu z1h<`nJE0I9Y&M7I-!82i8>}p=zrDTK+Wo_E!B<6shnTxv1T`EiOa!}K)D8>#A=ZJh zw^QU^6(Wp6r3Yf#Ykpp>^U9?I+ECMsuu&UFC7`saZacaWrE-`pW_zF=_8GHLg z?ln-*T|GYdy6`J8dY|xzmiBxj7?zCXNtGPgei2lrCVcD{g9fLjPv(#@VPzO-4EARR z6_TLpuiFu%(SbC;N|FfHH`9k~2B|jz=Lba1P&~b%4DJNH9}xNR*1NFY4~W`tFf(!N z{a)nWu*}48_Iu&y*>}v$9HfQs5}*cltPhsHJ0bY5&*DFZ9uz@EIuz?36qRxKKsku1 zioGKu_lf8}IU$&(k}8ZnB7AHE)La$2JxXpX;1y=7idEH)8) zuui$yXh)n7mhCD@Y@Z;uO>7_=!}F(HY)p524Zzw{F1S>Kk2pAvjUo6tDYU7QDk%|2 z4+U})lO$sg)>%<{`P;#y1dkC&j|fbe!o=hrk*~FZNCeU&hMjeRNJCPO7=G5p!5B#T zYeon|aCKVfftDkNz|k3@FSHz~YQ)w{u`QswsKVkN%=4o3^fke31g8*OuLv$k$HQZ< zxUkL!rx0APxahnKPMP3(#b?gD;21-2f6GW>2(CV+cxhJ;ioLsJ?rv#z^&l*Im-H`d z$l;fzCfv@DqeiDDHq8wAg&V?ws4z@Ld_xJjd~*1% zz`}!L?>?Ekzck{z!J%X@T-_)A&-2{DT->uHxS?fX#u^rF$1<`oc9slG=Yff3XUXas z&SpsL&6c^jHk%PA&Cb8{A)yCzv>2 z2L02Ch+)1QXsWrIkC_EhmNP3bV-3p`FDy2F(_>*`>H>)kzmvGGiM@p~_hjk)>w;}b z4Mr}MzA3C4j&2b}M~wt#?7>)(^*Bx<8whoc&MXjg4OpvOAVhqpvQo?(yD3OSb-}5_XrCikd?#;9b*>44tzqQZS;_kLu2n5nR`}> z{ClEMT011DF5NRJIFJlQOwUMv1ZOamHC;^^(EfoLYgiM~hZws$jUNxO=4y(cb{9+( z`f6GGO%8!fbCRx>!^U%^=VNcJ%x$)no`izg1RnQz0p!f02fo2tl4Nav=}5@mr6)bVl-TzvT28bx(yPi5>Xrc>tm7E}th^vcjtYB8@!HtiDswwZU%4SToz!BH zt zRc0>jkL_t6szoOw%k&{wn7bXDz`CL{bvsEsF_az@*d=oZN*~=ATwS$G`c>gm=!jm9 z>ST|Ue=1$`gWwm*9_evQARNB;$V#p*GU05G95sgOg-jiLd!>F|dUT6n;JLXtM)%6- zb(9?TP>La@-$-vnUkILmE4_>RLeOZR^h$0298@OzWyRvwL%$ zj`bVTtNRpI9_v3!Zva&}#-VKD(B&#k-O)z>$UNOPOBEaZd zQXi8^N~zFH>02^7<|t(hZr{!*WeBeSoKeaUD1C=URfQ2PPR_~&>hyo5f8nP=_o(+=Bo{?6mSt^d8)wgpioXhs(I>~CL4u6JTFkL|1bop9#$lk37@fYjC(}6Uc?xhl-su$5H(~BshWQo#`IbxM1c8Q7}bHo}DTVN?kl158pgV$xmtLa7X)~V$4 zicehdr@=#~Li;l8YUI|bF1ji;!EUGe-px~OFnC$^Rw%vR(W?!;D^#@J(aRXzuFU9V z2(F*V=w%4>u2Q9~_l8wNexY3Z;Ya1vC7&wozaX%uyGX@1#X9_R` z1)j|mUshQRpt zjB$p*_>QdRDWjcA@3!oID<{h=y*pL3+tF)4(7Q`{7r1aOy{{6z$4xKs@I6b(cT1Pv z8XQaZDUZLUq4+&ywNDM@3nQ7}b)Wj$4{Wh}h+@Ceha9h-;dQ@?4mn;K1Fr{^cZuyK z48ikj8Lte1*WW3xH0dk-<{$`*E~=O3{2OO}~!TH#dwd{A9`{#pVJ%YiuYsw>P%`v@or;G!Ygi^)rK}}M)y``0>-`3QK`C*~GrEpuz)M-t{ z_TuQm_lw2C)Ru+as0T+6R*2=^wfqz-S->sXl`u2qku2Zb)!Su z=E@=9VdeEpO^9(=^-nJcf*E^mlJtMl>UpWi*i#JM^y9|R~L-I?a^$%F$C8i zWOcz1>f*y}zcEDRa4g$yjG-Kkb2;F%ABvSR)WeBvzcGY*_$b?N451!QW`)EM>fuyY z59P?HN9i-J9LjAu^r+~JD+dGWIMIJq-f&wF21Gra&e|E?8P6#>Q>SN)4<61bkKgJL zxsT+|sS18$A`|qUQ=_l6yUs@}y-MGs)614`*zHx(J(@3D2~8ppZvU3i%Me_DoYBh= z=slm&%Mj7~Nk%VYp!ZJgZc7Y--MchSQ?uI`0=qL3?QTnqf!@2dyDc#UdOI|ZaC4|4 zC4H~f-*S$qMG+KTWm)u@Z$x#kE=&kdOMYz#VsEaNOSQ_sCkr>$H#IiYw-?7ZHFm%M zqu}2}La)MewZ|VX!AOAFb9EhmJ|`2b&ehkoT2=$F`heDtI#vV2>H|7@)UZl(EU6D_ zcQ0ZHp66+IFJj0;6+A@4%pt)Tu{vM7dl6$`bwS1|Ltyn`?cUKC0;`V@U503eKx*ml zCW6$T($)1ja!A+Khb4bDh>39rvN-ew~gsI_?b!?$>KCy}ua@+;1T6 zcbH0#VsDF<`@#KsS(xyjw@s;UzJn?q&Ar>37K{{01irRtk3W?_tO)6D(fKdi4HiL# zEqcsNR)q-By{Ppbr$S^@cu_}toC=J=>q}X&GX&3DGZh#j6}Dw6Fvcou*SNEqIx`?9 z@5oeO3@Yr*RA2}y?8tgSEv6?Ug8{0ma19H7FlGZiS$y;|>cDnwR=y*k?G xR4^b^_>K04xoGVz>9;dywFdZw_(k~j!>>Pn75ELnZyCd%OSu diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto index a9ce38080..a832628bb 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/authentication.proto @@ -170,7 +170,16 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; +} +// GetProviderSignOutRequest asks for the URL that ends the session at the +// identity provider. It is a separate call rather than a field on the Logout +// response because `saas.accounts.v1` is additive: an RPC's response type is +// never changed (module/CONTRACT_VERSIONING.md), and every client generated +// against `Logout` returning Empty would have to be rebuilt. Asking separately +// also sequences better — a client can hold the URL before it signs out, so it +// has somewhere to send the person the moment the host session ends. +message GetProviderSignOutRequest { // id_token_hint is the provider's id_token for the session being ended, as // the client still holds it. It is optional and client-supplied on purpose: // the host never retains a provider id_token — it has no other use for one, @@ -179,17 +188,17 @@ message LogoutRequest { // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case // where no hint is available. The provider may then ask the person to confirm // which session they are ending. - string id_token_hint = 2; + string id_token_hint = 1; } -// LogoutResponse carries what the host cannot do on the person's behalf. The -// host ends its own session unconditionally; reaching the identity provider +// GetProviderSignOutResponse carries what the host cannot do on the person's +// behalf. The host ends its own session itself; reaching the identity provider // requires the person's own user agent, because the provider's session cookie // is in their browser and not in any request accounts can make. -message LogoutResponse { +message GetProviderSignOutResponse { // end_session_url is the provider's `end_session_endpoint` with the // RP-initiated logout parameters already applied, for the client to send the - // person's browser to after the host session has ended. Empty when this + // person's browser to once the host session has ended. Empty when this // deployment's provider publishes no `end_session_endpoint`, or when the // configured provider has no OIDC sign-out at all (the development fixture): // an empty value is the whole answer, and a client must treat it as "nothing @@ -483,10 +492,7 @@ service AuthService { }; } - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - rpc Logout(LogoutRequest) returns (LogoutResponse) { + rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE @@ -504,6 +510,33 @@ service AuthService { }; } + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + rpc GetProviderSignOut(GetProviderSignOutRequest) returns (GetProviderSignOutResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_PUBLIC + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_AUTHENTICATION + request_sensitivity: SENSITIVITY_SECRET + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + option (google.api.http) = { + post: "/v1/auth/provider-sign-out" + body: "*" + }; + } + rpc GetJWKS(google.protobuf.Empty) returns (JWKSResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 559d627f42b72bdf85139b11e60276b19e43616e..86efa58a36c8f6dad6118990ab4c46615e344162 100644 GIT binary patch delta 9125 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 diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto index a9ce38080..a832628bb 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/authentication.proto @@ -170,7 +170,16 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; +} +// GetProviderSignOutRequest asks for the URL that ends the session at the +// identity provider. It is a separate call rather than a field on the Logout +// response because `saas.accounts.v1` is additive: an RPC's response type is +// never changed (module/CONTRACT_VERSIONING.md), and every client generated +// against `Logout` returning Empty would have to be rebuilt. Asking separately +// also sequences better — a client can hold the URL before it signs out, so it +// has somewhere to send the person the moment the host session ends. +message GetProviderSignOutRequest { // id_token_hint is the provider's id_token for the session being ended, as // the client still holds it. It is optional and client-supplied on purpose: // the host never retains a provider id_token — it has no other use for one, @@ -179,17 +188,17 @@ message LogoutRequest { // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case // where no hint is available. The provider may then ask the person to confirm // which session they are ending. - string id_token_hint = 2; + string id_token_hint = 1; } -// LogoutResponse carries what the host cannot do on the person's behalf. The -// host ends its own session unconditionally; reaching the identity provider +// GetProviderSignOutResponse carries what the host cannot do on the person's +// behalf. The host ends its own session itself; reaching the identity provider // requires the person's own user agent, because the provider's session cookie // is in their browser and not in any request accounts can make. -message LogoutResponse { +message GetProviderSignOutResponse { // end_session_url is the provider's `end_session_endpoint` with the // RP-initiated logout parameters already applied, for the client to send the - // person's browser to after the host session has ended. Empty when this + // person's browser to once the host session has ended. Empty when this // deployment's provider publishes no `end_session_endpoint`, or when the // configured provider has no OIDC sign-out at all (the development fixture): // an empty value is the whole answer, and a client must treat it as "nothing @@ -483,10 +492,7 @@ service AuthService { }; } - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - rpc Logout(LogoutRequest) returns (LogoutResponse) { + rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE @@ -504,6 +510,33 @@ service AuthService { }; } + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + rpc GetProviderSignOut(GetProviderSignOutRequest) returns (GetProviderSignOutResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_PUBLIC + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_AUTHENTICATION + request_sensitivity: SENSITIVITY_SECRET + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + option (google.api.http) = { + post: "/v1/auth/provider-sign-out" + body: "*" + }; + } + rpc GetJWKS(google.protobuf.Empty) returns (JWKSResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index dd43f2055..ef9b3c958 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0", + "digest": "sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade", "services": [ { "name": "ModuleCapabilitiesService", @@ -66,7 +66,7 @@ "kind": "protobuf", "package": "", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0", + "digest": "sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade", "services": [ { "name": "ArtifactService", @@ -178,6 +178,7 @@ "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", "/saas.accounts.v1.AuthService/ExchangeClientToken", "/saas.accounts.v1.AuthService/GetJWKS", + "/saas.accounts.v1.AuthService/GetProviderSignOut", "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "/saas.accounts.v1.AuthService/Logout", "/saas.accounts.v1.AuthService/RefreshToken", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 08ce1e3a9..f9a7ff511 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 + digest: sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade - endpoint: connect kind: protobuf package: "" path: contracts/api/accounts/connect/contract.binpb - digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 + digest: sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index b508aa4ed..ee7249d7c 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -2,7 +2,7 @@ > Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`. -Inventory: **301 RPCs** across **42 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **302 RPCs** across **42 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor. @@ -54,6 +54,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge` | unary | `POST /v1/auth/mfa/webauthn/complete` | `public` | exposure=PUBLIC; tenant=NONE; authentication_factor_attempt=true | — | — | SUCCESS: saas.auth.login, saas.auth.mfa_challenge_completed, saas.mfa.webauthn_used | FORBIDDEN / AUTHENTICATION | SECRET → SECRET | Verify WebAuthn and atomically consume the MFA login transaction. | | `/saas.accounts.v1.AuthService/ExchangeClientToken` | unary | `POST /v1/auth/token` | `public` | exposure=PUBLIC; tenant=NONE | — | — | SUCCESS: saas.auth.login | FORBIDDEN / AUTHENTICATION | SECRET → SECRET | Exchange a registered client's authorization code or rotating refresh token for tokens. | | `/saas.accounts.v1.AuthService/GetJWKS` | unary | `GET /v1/auth/.well-known/jwks.json` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / PUBLIC | PUBLIC → PUBLIC | Public JWKS for JWT signature verification. | +| `/saas.accounts.v1.AuthService/GetProviderSignOut` | unary | `POST /v1/auth/provider-sign-out` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / AUTHENTICATION | SECRET → CONFIDENTIAL | The identity provider's RP-initiated logout URL, for a client to end the provider's own session. | | `/saas.accounts.v1.AuthService/IssueClientAuthorizationCode` | unary | `POST /v1/auth/clients/authorize` | `auth` | exposure=AUTHENTICATED; tenant=NONE | — | — | SUCCESS: saas.auth.client_authorized | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | Issue the one-time code the host redirects back to a registered client with. | | `/saas.accounts.v1.AuthService/Logout` | unary | `POST /v1/auth/logout` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / AUTHENTICATION | SECRET → CONFIDENTIAL | Revoke the caller's session and its refresh-token family. | | `/saas.accounts.v1.AuthService/RefreshToken` | unary | `POST /v1/auth/refresh` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / AUTHENTICATION | SECRET → SECRET | Exchange a refresh token for a new access token, rotating the refresh token. | @@ -318,7 +319,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `org_admin`: 71 - `org_member`: 57 - `platform_admin`: 29 -- `public`: 32 +- `public`: 33 ## Three vocabularies diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers.go b/module/services/accounts/code/pkg/adapters/connect_handlers.go index a7a18b3a2..5fbea1b89 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers.go @@ -596,12 +596,15 @@ func (h *authConnectHandler) RefreshToken(ctx context.Context, req *connect.Requ func (h *authConnectHandler) SwitchOrganization(ctx context.Context, req *connect.Request[gen.SwitchOrganizationRequest]) (*connect.Response[gen.SwitchOrganizationResponse], error) { return unary(ctx, req, h.inner.SwitchOrganization) } -func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[gen.LogoutResponse], error) { +func (h *authConnectHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { req.Msg.RefreshToken = token }) return unary(ctx, req, h.inner.Logout) } +func (h *authConnectHandler) GetProviderSignOut(ctx context.Context, req *connect.Request[gen.GetProviderSignOutRequest]) (*connect.Response[gen.GetProviderSignOutResponse], error) { + return unary(ctx, req, h.inner.GetProviderSignOut) +} func (h *authConnectHandler) ValidateClientAuthorization(ctx context.Context, req *connect.Request[gen.ValidateClientAuthorizationRequest]) (*connect.Response[gen.ValidateClientAuthorizationResponse], error) { return unary(ctx, req, h.inner.ValidateClientAuthorization) } diff --git a/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go b/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go index 4697b2d8d..c0bd035f6 100644 --- a/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go +++ b/module/services/accounts/code/pkg/adapters/rest_refresh_cookie_chain_test.go @@ -16,6 +16,7 @@ import ( "golang.org/x/net/http2/h2c" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/emptypb" gen "accounts/pkg/gen/saas/accounts/v1" "accounts/pkg/gen/saas/accounts/v1/accountsv1connect" @@ -50,11 +51,11 @@ func (h *chainAuthHandler) RefreshToken(ctx context.Context, req *connect.Reques }) } -func (h *chainAuthHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[gen.LogoutResponse], error) { +func (h *chainAuthHandler) Logout(ctx context.Context, req *connect.Request[gen.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { refreshTokenFromCookie(req.Header(), req.Msg.GetRefreshToken(), func(token string) { req.Msg.RefreshToken = token }) - return unary(ctx, req, func(_ context.Context, msg *gen.LogoutRequest) (*gen.LogoutResponse, error) { + return unary(ctx, req, func(_ context.Context, msg *gen.LogoutRequest) (*emptypb.Empty, error) { h.saw("logout", msg.GetRefreshToken()) - return &gen.LogoutResponse{}, nil + return &emptypb.Empty{}, nil }) } diff --git a/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go b/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go index 20f82c23a..a76bd5b3e 100644 --- a/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go +++ b/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go @@ -168,6 +168,7 @@ var catalogRESTExactRoutes = map[string]struct{}{ "POST /v1/auth/mfa/webauthn/begin": {}, "POST /v1/auth/mfa/webauthn/complete": {}, "POST /v1/auth/oauth/begin": {}, + "POST /v1/auth/provider-sign-out": {}, "POST /v1/auth/refresh": {}, "POST /v1/auth/switch-organization": {}, "POST /v1/auth/token": {}, diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index 62a650011..126565188 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -1308,7 +1308,7 @@ func (s *AuthServer) BeginOAuth(ctx context.Context, req *gen.BeginOAuthRequest) return &gen.BeginOAuthResponse{State: state}, nil } -func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*gen.LogoutResponse, error) { +func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*emptypb.Empty, error) { if err := Validate(req); err != nil { return nil, err } @@ -1324,8 +1324,7 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*gen.L if sessionID, ok := auth.VerifiedSessionID(ctx); ok { verifiedSession = sessionID.String() } - response, err := service.Logout(ctx, req, accessToken, verifiedSession) - if err != nil { + if err := service.Logout(ctx, req, accessToken, verifiedSession); err != nil { // An incomplete sign-out is reported as retryable rather than as success: // the family is revoked, so nothing refreshes, but an already-issued access // token stays usable until it expires and the caller may want to retry. @@ -1335,7 +1334,14 @@ func (s *AuthServer) Logout(ctx context.Context, req *gen.LogoutRequest) (*gen.L } return nil, err } - return response, nil + return &emptypb.Empty{}, nil +} + +// GetProviderSignOut answers the provider's RP-initiated logout URL. No +// Validate call: the one field is an optional opaque hint with no shape this +// host asserts, and a request carrying none is the ordinary case. +func (s *AuthServer) GetProviderSignOut(ctx context.Context, req *gen.GetProviderSignOutRequest) (*gen.GetProviderSignOutResponse, error) { + return service.GetProviderSignOut(ctx, req) } // bearerFromContext extracts the bearer token from the incoming diff --git a/module/services/accounts/code/pkg/business/auth.go b/module/services/accounts/code/pkg/business/auth.go index 1b2943c09..e3f17b437 100644 --- a/module/services/accounts/code/pkg/business/auth.go +++ b/module/services/accounts/code/pkg/business/auth.go @@ -498,11 +498,11 @@ func (s *Service) BeginOAuth(ctx context.Context, provider, redirectURI string) // returned directly, so a failed access-marker write cannot discard the durable // half. Work that succeeded is kept — each step is idempotent, so a retry // completes the remainder rather than starting over. -func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) (*gen.LogoutResponse, error) { +func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToken, sessionID string) error { w := wool.Get(ctx).In("Logout") if s.minter == nil { - return nil, w.NewError("auth path not wired: minter missing") + return w.NewError("auth path not wired: minter missing") } var incomplete []string if accessToken != "" { @@ -518,23 +518,32 @@ func (s *Service) Logout(ctx context.Context, req *gen.LogoutRequest, accessToke } } if err := s.minter.Revoke(ctx, req.RefreshToken); err != nil { - return nil, err + return err } if len(incomplete) > 0 { // The family is revoked, so nothing can be refreshed; what is still live is // the access half, until its natural expiry. The caller is told, because the // difference between "signed out" and "signed out except for a few minutes" // is theirs to act on. - return nil, fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) - } - // The host session has ended. Ending the session at the identity provider - // needs the person's own browser — the provider's session cookie is there - // and in nothing accounts can send — so the complete answer is the URL for - // the client to send them to, and an empty one when this provider publishes - // no end_session_endpoint. It is computed only after every revocation held: - // a client told "now go here" while its host session is still live would - // sign out of the provider and stay signed in here. - return &gen.LogoutResponse{EndSessionUrl: s.endSessionURL(ctx, req.GetIdTokenHint())}, nil + return fmt.Errorf("%w: %s", ErrLogoutIncomplete, strings.Join(incomplete, " and ")) + } + // Ending the session at the identity provider is the other half, and it is + // not this call's to do: the provider's session cookie is in the person's + // browser and in nothing accounts can send. GetProviderSignOut answers the + // URL that does it. + return nil +} + +// GetProviderSignOut answers the identity provider's RP-initiated logout URL, +// with the client-supplied id_token_hint applied, or an empty one when this +// deployment's provider offers no such sign-out. It reads only configuration +// and the hint the caller passed, so it needs no session — which is the point: +// a client asks for it around a sign-out, when the credential it would +// otherwise present is exactly the one that has just been revoked. +func (s *Service) GetProviderSignOut(ctx context.Context, req *gen.GetProviderSignOutRequest) (*gen.GetProviderSignOutResponse, error) { + return &gen.GetProviderSignOutResponse{ + EndSessionUrl: s.endSessionURL(ctx, req.GetIdTokenHint()), + }, nil } // ErrLogoutIncomplete reports a sign-out whose refresh family was revoked while an diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index 8779160eb..7d1c38250 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -110,6 +110,7 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "AuthService/CompleteMFAChallenge": "Consume a one-use MFA login transaction and issue the session.", "AuthService/CompleteWebAuthnMFAChallenge": "Verify WebAuthn and atomically consume the MFA login transaction.", "AuthService/GetJWKS": "Public JWKS for JWT signature verification.", + "AuthService/GetProviderSignOut": "The identity provider's RP-initiated logout URL, for a client to end the provider's own session.", "AuthService/Logout": "Revoke the caller's session and its refresh-token family.", "AuthService/RefreshToken": "Exchange a refresh token for a new access token, rotating the refresh token.", "AuthService/SwitchOrganization": "Exchange the active device session for an access token scoped to another current membership.", diff --git a/module/services/accounts/code/pkg/business/provider_signout_test.go b/module/services/accounts/code/pkg/business/provider_signout_test.go index 9a19e0e74..f74fcac64 100644 --- a/module/services/accounts/code/pkg/business/provider_signout_test.go +++ b/module/services/accounts/code/pkg/business/provider_signout_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "accounts/pkg/auth" + gen "accounts/pkg/gen/saas/accounts/v1" ) const testEndSession = "https://idp.example.com/oidc/logout" @@ -93,6 +94,41 @@ func TestTheFixtureProviderOffersNoProviderSignOut(t *testing.T) { require.Contains(t, err.Error(), "verifies logout tokens") } +// The RPC answers an empty URL rather than an error when there is no provider +// sign-out, and it needs no session: a client asks for it around a sign-out, +// when the credential it would otherwise present is the one just revoked. +func TestGetProviderSignOutAnswersTheURLOrAnEmptyOne(t *testing.T) { + configured := signOutService(ProviderSignOut{ + EndSessionEndpoint: testEndSession, + ClientID: "acme-host", + PostLogoutRedirectURI: "https://app.example.com/signed-out", + }) + response, err := configured.GetProviderSignOut(context.Background(), + &gen.GetProviderSignOutRequest{IdTokenHint: "the-provider-id-token"}) + require.NoError(t, err) + parsed, err := url.Parse(response.GetEndSessionUrl()) + require.NoError(t, err) + require.Equal(t, "the-provider-id-token", parsed.Query().Get("id_token_hint")) + require.Equal(t, "https://app.example.com/signed-out", parsed.Query().Get("post_logout_redirect_uri")) + + // No hint, and no provider at all: an empty URL both times, never an error. + response, err = configured.GetProviderSignOut(context.Background(), &gen.GetProviderSignOutRequest{}) + require.NoError(t, err) + require.Equal(t, "acme-host", mustQuery(t, response.GetEndSessionUrl(), "client_id")) + + response, err = signOutService(ProviderSignOut{}).GetProviderSignOut( + context.Background(), &gen.GetProviderSignOutRequest{IdTokenHint: "a-hint"}) + require.NoError(t, err) + require.Empty(t, response.GetEndSessionUrl()) +} + +func mustQuery(t *testing.T, raw, key string) string { + t.Helper() + parsed, err := url.Parse(raw) + require.NoError(t, err) + return parsed.Query().Get(key) +} + // A verified token that names only the provider's own session id is a request // this host cannot match: nothing here records a provider `sid`. It answers so // rather than acknowledging a sign-out that did not happen. diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index c83f81386..306d55c2c 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -539,11 +539,13 @@ func TestLogout(t *testing.T) { // No access token and no verified session: the body's refresh credential alone // must still end the family, which is what a non-browser caller presents. - logoutResp, err := testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") + err = testService.Logout(testCtx, &gen.LogoutRequest{RefreshToken: authResp.RefreshToken}, "", "") require.NoError(t, err) // This fixture configures no identity provider sign-out, so there is nothing // for the client to complete. An empty URL is the whole answer. - require.Empty(t, logoutResp.GetEndSessionUrl()) + signOut, err := testService.GetProviderSignOut(testCtx, &gen.GetProviderSignOutRequest{}) + require.NoError(t, err) + require.Empty(t, signOut.GetEndSessionUrl()) _, err = testService.RefreshToken(testCtx, &gen.RefreshTokenRequest{ RefreshToken: authResp.RefreshToken, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go index bb5c5ad8d..1c40ee00d 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/authentication.connect.go @@ -57,6 +57,9 @@ const ( AuthServiceSwitchOrganizationProcedure = "/saas.accounts.v1.AuthService/SwitchOrganization" // AuthServiceLogoutProcedure is the fully-qualified name of the AuthService's Logout RPC. AuthServiceLogoutProcedure = "/saas.accounts.v1.AuthService/Logout" + // AuthServiceGetProviderSignOutProcedure is the fully-qualified name of the AuthService's + // GetProviderSignOut RPC. + AuthServiceGetProviderSignOutProcedure = "/saas.accounts.v1.AuthService/GetProviderSignOut" // AuthServiceGetJWKSProcedure is the fully-qualified name of the AuthService's GetJWKS RPC. AuthServiceGetJWKSProcedure = "/saas.accounts.v1.AuthService/GetJWKS" // AuthServiceValidateClientAuthorizationProcedure is the fully-qualified name of the AuthService's @@ -90,10 +93,17 @@ type AuthServiceClient interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *connect.Request[v1.SwitchOrganizationRequest]) (*connect.Response[v1.SwitchOrganizationResponse], error) - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + GetProviderSignOut(context.Context, *connect.Request[v1.GetProviderSignOutRequest]) (*connect.Response[v1.GetProviderSignOutResponse], error) GetJWKS(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -167,12 +177,18 @@ func NewAuthServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(authServiceMethods.ByName("SwitchOrganization")), connect.WithClientOptions(opts...), ), - logout: connect.NewClient[v1.LogoutRequest, v1.LogoutResponse]( + logout: connect.NewClient[v1.LogoutRequest, emptypb.Empty]( httpClient, baseURL+AuthServiceLogoutProcedure, connect.WithSchema(authServiceMethods.ByName("Logout")), connect.WithClientOptions(opts...), ), + getProviderSignOut: connect.NewClient[v1.GetProviderSignOutRequest, v1.GetProviderSignOutResponse]( + httpClient, + baseURL+AuthServiceGetProviderSignOutProcedure, + connect.WithSchema(authServiceMethods.ByName("GetProviderSignOut")), + connect.WithClientOptions(opts...), + ), getJWKS: connect.NewClient[emptypb.Empty, v1.JWKSResponse]( httpClient, baseURL+AuthServiceGetJWKSProcedure, @@ -209,7 +225,8 @@ type authServiceClient struct { completeWebAuthnMFAChallenge *connect.Client[v1.CompleteWebAuthnMFAChallengeRequest, v1.CompleteMFAChallengeResponse] refreshToken *connect.Client[v1.RefreshTokenRequest, v1.RefreshTokenResponse] switchOrganization *connect.Client[v1.SwitchOrganizationRequest, v1.SwitchOrganizationResponse] - logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse] + logout *connect.Client[v1.LogoutRequest, emptypb.Empty] + getProviderSignOut *connect.Client[v1.GetProviderSignOutRequest, v1.GetProviderSignOutResponse] getJWKS *connect.Client[emptypb.Empty, v1.JWKSResponse] validateClientAuthorization *connect.Client[v1.ValidateClientAuthorizationRequest, v1.ValidateClientAuthorizationResponse] issueClientAuthorizationCode *connect.Client[v1.IssueClientAuthorizationCodeRequest, v1.IssueClientAuthorizationCodeResponse] @@ -252,10 +269,15 @@ func (c *authServiceClient) SwitchOrganization(ctx context.Context, req *connect } // Logout calls saas.accounts.v1.AuthService.Logout. -func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { +func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { return c.logout.CallUnary(ctx, req) } +// GetProviderSignOut calls saas.accounts.v1.AuthService.GetProviderSignOut. +func (c *authServiceClient) GetProviderSignOut(ctx context.Context, req *connect.Request[v1.GetProviderSignOutRequest]) (*connect.Response[v1.GetProviderSignOutResponse], error) { + return c.getProviderSignOut.CallUnary(ctx, req) +} + // GetJWKS calls saas.accounts.v1.AuthService.GetJWKS. func (c *authServiceClient) GetJWKS(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) { return c.getJWKS.CallUnary(ctx, req) @@ -296,10 +318,17 @@ type AuthServiceHandler interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *connect.Request[v1.SwitchOrganizationRequest]) (*connect.Response[v1.SwitchOrganizationResponse], error) - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + GetProviderSignOut(context.Context, *connect.Request[v1.GetProviderSignOutRequest]) (*connect.Response[v1.GetProviderSignOutResponse], error) GetJWKS(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -375,6 +404,12 @@ func NewAuthServiceHandler(svc AuthServiceHandler, opts ...connect.HandlerOption connect.WithSchema(authServiceMethods.ByName("Logout")), connect.WithHandlerOptions(opts...), ) + authServiceGetProviderSignOutHandler := connect.NewUnaryHandler( + AuthServiceGetProviderSignOutProcedure, + svc.GetProviderSignOut, + connect.WithSchema(authServiceMethods.ByName("GetProviderSignOut")), + connect.WithHandlerOptions(opts...), + ) authServiceGetJWKSHandler := connect.NewUnaryHandler( AuthServiceGetJWKSProcedure, svc.GetJWKS, @@ -417,6 +452,8 @@ func NewAuthServiceHandler(svc AuthServiceHandler, opts ...connect.HandlerOption authServiceSwitchOrganizationHandler.ServeHTTP(w, r) case AuthServiceLogoutProcedure: authServiceLogoutHandler.ServeHTTP(w, r) + case AuthServiceGetProviderSignOutProcedure: + authServiceGetProviderSignOutHandler.ServeHTTP(w, r) case AuthServiceGetJWKSProcedure: authServiceGetJWKSHandler.ServeHTTP(w, r) case AuthServiceValidateClientAuthorizationProcedure: @@ -462,10 +499,14 @@ func (UnimplementedAuthServiceHandler) SwitchOrganization(context.Context, *conn return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.SwitchOrganization is not implemented")) } -func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { +func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[emptypb.Empty], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.Logout is not implemented")) } +func (UnimplementedAuthServiceHandler) GetProviderSignOut(context.Context, *connect.Request[v1.GetProviderSignOutRequest]) (*connect.Response[v1.GetProviderSignOutResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.GetProviderSignOut is not implemented")) +} + func (UnimplementedAuthServiceHandler) GetJWKS(context.Context, *connect.Request[emptypb.Empty]) (*connect.Response[v1.JWKSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.AuthService.GetJWKS is not implemented")) } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go index 7a299030d..a2d9d0446 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.go @@ -926,17 +926,8 @@ func (x *SwitchOrganizationResponse) GetExpiresIn() int64 { } type LogoutRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` - // id_token_hint is the provider's id_token for the session being ended, as - // the client still holds it. It is optional and client-supplied on purpose: - // the host never retains a provider id_token — it has no other use for one, - // and storing a provider credential to hand back later is a liability, not a - // feature. When it is absent the end-session URL carries `client_id` instead, - // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case - // where no hint is available. The provider may then ask the person to confirm - // which session they are ending. - IdTokenHint string `protobuf:"bytes,2,opt,name=id_token_hint,json=idTokenHint,proto3" json:"id_token_hint,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -978,22 +969,74 @@ func (x *LogoutRequest) GetRefreshToken() string { return "" } -func (x *LogoutRequest) GetIdTokenHint() string { +// GetProviderSignOutRequest asks for the URL that ends the session at the +// identity provider. It is a separate call rather than a field on the Logout +// response because `saas.accounts.v1` is additive: an RPC's response type is +// never changed (module/CONTRACT_VERSIONING.md), and every client generated +// against `Logout` returning Empty would have to be rebuilt. Asking separately +// also sequences better — a client can hold the URL before it signs out, so it +// has somewhere to send the person the moment the host session ends. +type GetProviderSignOutRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // id_token_hint is the provider's id_token for the session being ended, as + // the client still holds it. It is optional and client-supplied on purpose: + // the host never retains a provider id_token — it has no other use for one, + // and storing a provider credential to hand back later is a liability, not a + // feature. When it is absent the end-session URL carries `client_id` instead, + // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case + // where no hint is available. The provider may then ask the person to confirm + // which session they are ending. + IdTokenHint string `protobuf:"bytes,1,opt,name=id_token_hint,json=idTokenHint,proto3" json:"id_token_hint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderSignOutRequest) Reset() { + *x = GetProviderSignOutRequest{} + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderSignOutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderSignOutRequest) ProtoMessage() {} + +func (x *GetProviderSignOutRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetProviderSignOutRequest.ProtoReflect.Descriptor instead. +func (*GetProviderSignOutRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{15} +} + +func (x *GetProviderSignOutRequest) GetIdTokenHint() string { if x != nil { return x.IdTokenHint } return "" } -// LogoutResponse carries what the host cannot do on the person's behalf. The -// host ends its own session unconditionally; reaching the identity provider +// GetProviderSignOutResponse carries what the host cannot do on the person's +// behalf. The host ends its own session itself; reaching the identity provider // requires the person's own user agent, because the provider's session cookie // is in their browser and not in any request accounts can make. -type LogoutResponse struct { +type GetProviderSignOutResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // end_session_url is the provider's `end_session_endpoint` with the // RP-initiated logout parameters already applied, for the client to send the - // person's browser to after the host session has ended. Empty when this + // person's browser to once the host session has ended. Empty when this // deployment's provider publishes no `end_session_endpoint`, or when the // configured provider has no OIDC sign-out at all (the development fixture): // an empty value is the whole answer, and a client must treat it as "nothing @@ -1003,21 +1046,21 @@ type LogoutResponse struct { sizeCache protoimpl.SizeCache } -func (x *LogoutResponse) Reset() { - *x = LogoutResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] +func (x *GetProviderSignOutResponse) Reset() { + *x = GetProviderSignOutResponse{} + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *LogoutResponse) String() string { +func (x *GetProviderSignOutResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*LogoutResponse) ProtoMessage() {} +func (*GetProviderSignOutResponse) ProtoMessage() {} -func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[15] +func (x *GetProviderSignOutResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1028,12 +1071,12 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. -func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{15} +// Deprecated: Use GetProviderSignOutResponse.ProtoReflect.Descriptor instead. +func (*GetProviderSignOutResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{16} } -func (x *LogoutResponse) GetEndSessionUrl() string { +func (x *GetProviderSignOutResponse) GetEndSessionUrl() string { if x != nil { return x.EndSessionUrl } @@ -1049,7 +1092,7 @@ type JWKSResponse struct { func (x *JWKSResponse) Reset() { *x = JWKSResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1061,7 +1104,7 @@ func (x *JWKSResponse) String() string { func (*JWKSResponse) ProtoMessage() {} func (x *JWKSResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[16] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1074,7 +1117,7 @@ func (x *JWKSResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use JWKSResponse.ProtoReflect.Descriptor instead. func (*JWKSResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{16} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{17} } func (x *JWKSResponse) GetKeysJson() string { @@ -1099,7 +1142,7 @@ type BeginOAuthRequest struct { func (x *BeginOAuthRequest) Reset() { *x = BeginOAuthRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1111,7 +1154,7 @@ func (x *BeginOAuthRequest) String() string { func (*BeginOAuthRequest) ProtoMessage() {} func (x *BeginOAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[17] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1124,7 +1167,7 @@ func (x *BeginOAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginOAuthRequest.ProtoReflect.Descriptor instead. func (*BeginOAuthRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{17} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{18} } func (x *BeginOAuthRequest) GetProvider() string { @@ -1152,7 +1195,7 @@ type BeginOAuthResponse struct { func (x *BeginOAuthResponse) Reset() { *x = BeginOAuthResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1164,7 +1207,7 @@ func (x *BeginOAuthResponse) String() string { func (*BeginOAuthResponse) ProtoMessage() {} func (x *BeginOAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[18] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1177,7 +1220,7 @@ func (x *BeginOAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginOAuthResponse.ProtoReflect.Descriptor instead. func (*BeginOAuthResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{18} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{19} } func (x *BeginOAuthResponse) GetState() string { @@ -1205,7 +1248,7 @@ type ClientAuthorizationRequest struct { func (x *ClientAuthorizationRequest) Reset() { *x = ClientAuthorizationRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1217,7 +1260,7 @@ func (x *ClientAuthorizationRequest) String() string { func (*ClientAuthorizationRequest) ProtoMessage() {} func (x *ClientAuthorizationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[19] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1230,7 +1273,7 @@ func (x *ClientAuthorizationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientAuthorizationRequest.ProtoReflect.Descriptor instead. func (*ClientAuthorizationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{19} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{20} } func (x *ClientAuthorizationRequest) GetClientId() string { @@ -1270,7 +1313,7 @@ type ValidateClientAuthorizationRequest struct { func (x *ValidateClientAuthorizationRequest) Reset() { *x = ValidateClientAuthorizationRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1282,7 +1325,7 @@ func (x *ValidateClientAuthorizationRequest) String() string { func (*ValidateClientAuthorizationRequest) ProtoMessage() {} func (x *ValidateClientAuthorizationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[20] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1295,7 +1338,7 @@ func (x *ValidateClientAuthorizationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ValidateClientAuthorizationRequest.ProtoReflect.Descriptor instead. func (*ValidateClientAuthorizationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{20} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{21} } func (x *ValidateClientAuthorizationRequest) GetAuthorization() *ClientAuthorizationRequest { @@ -1317,7 +1360,7 @@ type ValidateClientAuthorizationResponse struct { func (x *ValidateClientAuthorizationResponse) Reset() { *x = ValidateClientAuthorizationResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1329,7 +1372,7 @@ func (x *ValidateClientAuthorizationResponse) String() string { func (*ValidateClientAuthorizationResponse) ProtoMessage() {} func (x *ValidateClientAuthorizationResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[21] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1342,7 +1385,7 @@ func (x *ValidateClientAuthorizationResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use ValidateClientAuthorizationResponse.ProtoReflect.Descriptor instead. func (*ValidateClientAuthorizationResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{21} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{22} } func (x *ValidateClientAuthorizationResponse) GetClientName() string { @@ -1361,7 +1404,7 @@ type IssueClientAuthorizationCodeRequest struct { func (x *IssueClientAuthorizationCodeRequest) Reset() { *x = IssueClientAuthorizationCodeRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1373,7 +1416,7 @@ func (x *IssueClientAuthorizationCodeRequest) String() string { func (*IssueClientAuthorizationCodeRequest) ProtoMessage() {} func (x *IssueClientAuthorizationCodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[22] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1386,7 +1429,7 @@ func (x *IssueClientAuthorizationCodeRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use IssueClientAuthorizationCodeRequest.ProtoReflect.Descriptor instead. func (*IssueClientAuthorizationCodeRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{22} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{23} } func (x *IssueClientAuthorizationCodeRequest) GetAuthorization() *ClientAuthorizationRequest { @@ -1408,7 +1451,7 @@ type IssueClientAuthorizationCodeResponse struct { func (x *IssueClientAuthorizationCodeResponse) Reset() { *x = IssueClientAuthorizationCodeResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1420,7 +1463,7 @@ func (x *IssueClientAuthorizationCodeResponse) String() string { func (*IssueClientAuthorizationCodeResponse) ProtoMessage() {} func (x *IssueClientAuthorizationCodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[23] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1433,7 +1476,7 @@ func (x *IssueClientAuthorizationCodeResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use IssueClientAuthorizationCodeResponse.ProtoReflect.Descriptor instead. func (*IssueClientAuthorizationCodeResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{23} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{24} } func (x *IssueClientAuthorizationCodeResponse) GetCode() string { @@ -1464,7 +1507,7 @@ type AuthorizationCodeGrant struct { func (x *AuthorizationCodeGrant) Reset() { *x = AuthorizationCodeGrant{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1476,7 +1519,7 @@ func (x *AuthorizationCodeGrant) String() string { func (*AuthorizationCodeGrant) ProtoMessage() {} func (x *AuthorizationCodeGrant) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[24] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1489,7 +1532,7 @@ func (x *AuthorizationCodeGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizationCodeGrant.ProtoReflect.Descriptor instead. func (*AuthorizationCodeGrant) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{24} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{25} } func (x *AuthorizationCodeGrant) GetCode() string { @@ -1525,7 +1568,7 @@ type ClientRefreshTokenGrant struct { func (x *ClientRefreshTokenGrant) Reset() { *x = ClientRefreshTokenGrant{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1537,7 +1580,7 @@ func (x *ClientRefreshTokenGrant) String() string { func (*ClientRefreshTokenGrant) ProtoMessage() {} func (x *ClientRefreshTokenGrant) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[25] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1550,7 +1593,7 @@ func (x *ClientRefreshTokenGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientRefreshTokenGrant.ProtoReflect.Descriptor instead. func (*ClientRefreshTokenGrant) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{25} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{26} } func (x *ClientRefreshTokenGrant) GetRefreshToken() string { @@ -1574,7 +1617,7 @@ type ExchangeClientTokenRequest struct { func (x *ExchangeClientTokenRequest) Reset() { *x = ExchangeClientTokenRequest{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1586,7 +1629,7 @@ func (x *ExchangeClientTokenRequest) String() string { func (*ExchangeClientTokenRequest) ProtoMessage() {} func (x *ExchangeClientTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[26] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1599,7 +1642,7 @@ func (x *ExchangeClientTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExchangeClientTokenRequest.ProtoReflect.Descriptor instead. func (*ExchangeClientTokenRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{26} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{27} } func (x *ExchangeClientTokenRequest) GetClientId() string { @@ -1664,7 +1707,7 @@ type ExchangeClientTokenResponse struct { func (x *ExchangeClientTokenResponse) Reset() { *x = ExchangeClientTokenResponse{} - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1676,7 +1719,7 @@ func (x *ExchangeClientTokenResponse) String() string { func (*ExchangeClientTokenResponse) ProtoMessage() {} func (x *ExchangeClientTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_authentication_proto_msgTypes[27] + mi := &file_saas_accounts_v1_authentication_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1689,7 +1732,7 @@ func (x *ExchangeClientTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExchangeClientTokenResponse.ProtoReflect.Descriptor instead. func (*ExchangeClientTokenResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{27} + return file_saas_accounts_v1_authentication_proto_rawDescGZIP(), []int{28} } func (x *ExchangeClientTokenResponse) GetAccessToken() string { @@ -1792,11 +1835,12 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\x1aSwitchOrganizationResponse\x12!\n" + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12\x1d\n" + "\n" + - "expires_in\x18\x02 \x01(\x03R\texpiresIn\"a\n" + + "expires_in\x18\x02 \x01(\x03R\texpiresIn\"=\n" + "\rLogoutRequest\x12,\n" + - "\rrefresh_token\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\frefreshToken\x12\"\n" + - "\rid_token_hint\x18\x02 \x01(\tR\vidTokenHint\"8\n" + - "\x0eLogoutResponse\x12&\n" + + "\rrefresh_token\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\frefreshToken\"?\n" + + "\x19GetProviderSignOutRequest\x12\"\n" + + "\rid_token_hint\x18\x01 \x01(\tR\vidTokenHint\"D\n" + + "\x1aGetProviderSignOutResponse\x12&\n" + "\x0fend_session_url\x18\x01 \x01(\tR\rendSessionUrl\"+\n" + "\fJWKSResponse\x12\x1b\n" + "\tkeys_json\x18\x01 \x01(\tR\bkeysJson\"d\n" + @@ -1839,7 +1883,7 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12#\n" + "\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\x12\x1d\n" + "\n" + - "expires_in\x18\x03 \x01(\x03R\texpiresIn2\xaf\x12\n" + + "expires_in\x18\x03 \x01(\x03R\texpiresIn2\xd6\x13\n" + "\vAuthService\x12\x90\x01\n" + "\n" + "BeginOAuth\x12#.saas.accounts.v1.BeginOAuthRequest\x1a$.saas.accounts.v1.BeginOAuthResponse\"7\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x03X\x04`\x01\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/v1/auth/oauth/begin\x12\xc9\x01\n" + @@ -1857,8 +1901,9 @@ const file_saas_accounts_v1_authentication_proto_rawDesc = "" + "\x16saas.mfa.webauthn_used\x10\x02@\x01H\x02P\x04X\x04`\x01h\x01\x82\xd3\xe4\x93\x02#:\x01*\"\x1e/v1/auth/mfa/webauthn/complete\x12\x92\x01\n" + "\fRefreshToken\x12%.saas.accounts.v1.RefreshTokenRequest\x1a&.saas.accounts.v1.RefreshTokenResponse\"3\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x04`\x01\x82\xd3\xe4\x93\x02\x15:\x01*\"\x10/v1/auth/refresh\x12\xd1\x01\n" + "\x12SwitchOrganization\x12+.saas.accounts.v1.SwitchOrganizationRequest\x1a,.saas.accounts.v1.SwitchOrganizationResponse\"`\xc2\xf3\x185\b\x02\x10\x010\x01:#\n" + - "\x1fsaas.auth.organization_switched\x10\x02@\x01H\x05P\x03X\x04`\x01\x82\xd3\xe4\x93\x02!:\x01*\"\x1c/v1/auth/switch-organization\x12\x7f\n" + - "\x06Logout\x12\x1f.saas.accounts.v1.LogoutRequest\x1a .saas.accounts.v1.LogoutResponse\"2\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x03`\x01\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/v1/auth/logout\x12\x81\x01\n" + + "\x1fsaas.auth.organization_switched\x10\x02@\x01H\x05P\x03X\x04`\x01\x82\xd3\xe4\x93\x02!:\x01*\"\x1c/v1/auth/switch-organization\x12u\n" + + "\x06Logout\x12\x1f.saas.accounts.v1.LogoutRequest\x1a\x16.google.protobuf.Empty\"2\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x03`\x01\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/v1/auth/logout\x12\xae\x01\n" + + "\x12GetProviderSignOut\x12+.saas.accounts.v1.GetProviderSignOutRequest\x1a,.saas.accounts.v1.GetProviderSignOutResponse\"=\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x04X\x03`\x01\x82\xd3\xe4\x93\x02\x1f:\x01*\"\x1a/v1/auth/provider-sign-out\x12\x81\x01\n" + "\aGetJWKS\x12\x16.google.protobuf.Empty\x1a\x1e.saas.accounts.v1.JWKSResponse\">\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x01P\x01X\x01`\x01\x82\xd3\xe4\x93\x02 \x12\x1e/v1/auth/.well-known/jwks.json\x12\xc8\x01\n" + "\x1bValidateClientAuthorization\x124.saas.accounts.v1.ValidateClientAuthorizationRequest\x1a5.saas.accounts.v1.ValidateClientAuthorizationResponse\"<\xc2\xf3\x18\x14\b\x01\x10\x010\x01:\x02\x10\x01@\x01H\x02P\x03X\x03`\x01\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/v1/auth/clients/validate\x12\xe9\x01\n" + "\x1cIssueClientAuthorizationCode\x125.saas.accounts.v1.IssueClientAuthorizationCodeRequest\x1a6.saas.accounts.v1.IssueClientAuthorizationCodeResponse\"Z\xc2\xf3\x181\b\x02\x10\x010\x01:\x1f\n" + @@ -1879,7 +1924,7 @@ func file_saas_accounts_v1_authentication_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_authentication_proto_rawDescData } -var file_saas_accounts_v1_authentication_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_saas_accounts_v1_authentication_proto_msgTypes = make([]protoimpl.MessageInfo, 30) var file_saas_accounts_v1_authentication_proto_goTypes = []any{ (*OAuthCodeAuthentication)(nil), // 0: saas.accounts.v1.OAuthCodeAuthentication (*FixtureAuthentication)(nil), // 1: saas.accounts.v1.FixtureAuthentication @@ -1896,35 +1941,36 @@ var file_saas_accounts_v1_authentication_proto_goTypes = []any{ (*SwitchOrganizationRequest)(nil), // 12: saas.accounts.v1.SwitchOrganizationRequest (*SwitchOrganizationResponse)(nil), // 13: saas.accounts.v1.SwitchOrganizationResponse (*LogoutRequest)(nil), // 14: saas.accounts.v1.LogoutRequest - (*LogoutResponse)(nil), // 15: saas.accounts.v1.LogoutResponse - (*JWKSResponse)(nil), // 16: saas.accounts.v1.JWKSResponse - (*BeginOAuthRequest)(nil), // 17: saas.accounts.v1.BeginOAuthRequest - (*BeginOAuthResponse)(nil), // 18: saas.accounts.v1.BeginOAuthResponse - (*ClientAuthorizationRequest)(nil), // 19: saas.accounts.v1.ClientAuthorizationRequest - (*ValidateClientAuthorizationRequest)(nil), // 20: saas.accounts.v1.ValidateClientAuthorizationRequest - (*ValidateClientAuthorizationResponse)(nil), // 21: saas.accounts.v1.ValidateClientAuthorizationResponse - (*IssueClientAuthorizationCodeRequest)(nil), // 22: saas.accounts.v1.IssueClientAuthorizationCodeRequest - (*IssueClientAuthorizationCodeResponse)(nil), // 23: saas.accounts.v1.IssueClientAuthorizationCodeResponse - (*AuthorizationCodeGrant)(nil), // 24: saas.accounts.v1.AuthorizationCodeGrant - (*ClientRefreshTokenGrant)(nil), // 25: saas.accounts.v1.ClientRefreshTokenGrant - (*ExchangeClientTokenRequest)(nil), // 26: saas.accounts.v1.ExchangeClientTokenRequest - (*ExchangeClientTokenResponse)(nil), // 27: saas.accounts.v1.ExchangeClientTokenResponse - nil, // 28: saas.accounts.v1.AuthenticateRequest.ProfileEntry - (*User)(nil), // 29: saas.accounts.v1.User - (*emptypb.Empty)(nil), // 30: google.protobuf.Empty + (*GetProviderSignOutRequest)(nil), // 15: saas.accounts.v1.GetProviderSignOutRequest + (*GetProviderSignOutResponse)(nil), // 16: saas.accounts.v1.GetProviderSignOutResponse + (*JWKSResponse)(nil), // 17: saas.accounts.v1.JWKSResponse + (*BeginOAuthRequest)(nil), // 18: saas.accounts.v1.BeginOAuthRequest + (*BeginOAuthResponse)(nil), // 19: saas.accounts.v1.BeginOAuthResponse + (*ClientAuthorizationRequest)(nil), // 20: saas.accounts.v1.ClientAuthorizationRequest + (*ValidateClientAuthorizationRequest)(nil), // 21: saas.accounts.v1.ValidateClientAuthorizationRequest + (*ValidateClientAuthorizationResponse)(nil), // 22: saas.accounts.v1.ValidateClientAuthorizationResponse + (*IssueClientAuthorizationCodeRequest)(nil), // 23: saas.accounts.v1.IssueClientAuthorizationCodeRequest + (*IssueClientAuthorizationCodeResponse)(nil), // 24: saas.accounts.v1.IssueClientAuthorizationCodeResponse + (*AuthorizationCodeGrant)(nil), // 25: saas.accounts.v1.AuthorizationCodeGrant + (*ClientRefreshTokenGrant)(nil), // 26: saas.accounts.v1.ClientRefreshTokenGrant + (*ExchangeClientTokenRequest)(nil), // 27: saas.accounts.v1.ExchangeClientTokenRequest + (*ExchangeClientTokenResponse)(nil), // 28: saas.accounts.v1.ExchangeClientTokenResponse + nil, // 29: saas.accounts.v1.AuthenticateRequest.ProfileEntry + (*User)(nil), // 30: saas.accounts.v1.User + (*emptypb.Empty)(nil), // 31: google.protobuf.Empty } var file_saas_accounts_v1_authentication_proto_depIdxs = []int32{ - 28, // 0: saas.accounts.v1.AuthenticateRequest.profile:type_name -> saas.accounts.v1.AuthenticateRequest.ProfileEntry + 29, // 0: saas.accounts.v1.AuthenticateRequest.profile:type_name -> saas.accounts.v1.AuthenticateRequest.ProfileEntry 0, // 1: saas.accounts.v1.AuthenticateRequest.oauth_code:type_name -> saas.accounts.v1.OAuthCodeAuthentication 1, // 2: saas.accounts.v1.AuthenticateRequest.fixture:type_name -> saas.accounts.v1.FixtureAuthentication 2, // 3: saas.accounts.v1.AuthenticateRequest.header_jwt:type_name -> saas.accounts.v1.HeaderJWTAuthentication - 29, // 4: saas.accounts.v1.AuthenticateResponse.user:type_name -> saas.accounts.v1.User - 29, // 5: saas.accounts.v1.CompleteMFAChallengeResponse.user:type_name -> saas.accounts.v1.User - 19, // 6: saas.accounts.v1.ValidateClientAuthorizationRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest - 19, // 7: saas.accounts.v1.IssueClientAuthorizationCodeRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest - 24, // 8: saas.accounts.v1.ExchangeClientTokenRequest.authorization_code:type_name -> saas.accounts.v1.AuthorizationCodeGrant - 25, // 9: saas.accounts.v1.ExchangeClientTokenRequest.refresh_token:type_name -> saas.accounts.v1.ClientRefreshTokenGrant - 17, // 10: saas.accounts.v1.AuthService.BeginOAuth:input_type -> saas.accounts.v1.BeginOAuthRequest + 30, // 4: saas.accounts.v1.AuthenticateResponse.user:type_name -> saas.accounts.v1.User + 30, // 5: saas.accounts.v1.CompleteMFAChallengeResponse.user:type_name -> saas.accounts.v1.User + 20, // 6: saas.accounts.v1.ValidateClientAuthorizationRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest + 20, // 7: saas.accounts.v1.IssueClientAuthorizationCodeRequest.authorization:type_name -> saas.accounts.v1.ClientAuthorizationRequest + 25, // 8: saas.accounts.v1.ExchangeClientTokenRequest.authorization_code:type_name -> saas.accounts.v1.AuthorizationCodeGrant + 26, // 9: saas.accounts.v1.ExchangeClientTokenRequest.refresh_token:type_name -> saas.accounts.v1.ClientRefreshTokenGrant + 18, // 10: saas.accounts.v1.AuthService.BeginOAuth:input_type -> saas.accounts.v1.BeginOAuthRequest 3, // 11: saas.accounts.v1.AuthService.Authenticate:input_type -> saas.accounts.v1.AuthenticateRequest 5, // 12: saas.accounts.v1.AuthService.CompleteMFAChallenge:input_type -> saas.accounts.v1.CompleteMFAChallengeRequest 7, // 13: saas.accounts.v1.AuthService.BeginWebAuthnMFAChallenge:input_type -> saas.accounts.v1.BeginWebAuthnMFAChallengeRequest @@ -1932,24 +1978,26 @@ var file_saas_accounts_v1_authentication_proto_depIdxs = []int32{ 10, // 15: saas.accounts.v1.AuthService.RefreshToken:input_type -> saas.accounts.v1.RefreshTokenRequest 12, // 16: saas.accounts.v1.AuthService.SwitchOrganization:input_type -> saas.accounts.v1.SwitchOrganizationRequest 14, // 17: saas.accounts.v1.AuthService.Logout:input_type -> saas.accounts.v1.LogoutRequest - 30, // 18: saas.accounts.v1.AuthService.GetJWKS:input_type -> google.protobuf.Empty - 20, // 19: saas.accounts.v1.AuthService.ValidateClientAuthorization:input_type -> saas.accounts.v1.ValidateClientAuthorizationRequest - 22, // 20: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:input_type -> saas.accounts.v1.IssueClientAuthorizationCodeRequest - 26, // 21: saas.accounts.v1.AuthService.ExchangeClientToken:input_type -> saas.accounts.v1.ExchangeClientTokenRequest - 18, // 22: saas.accounts.v1.AuthService.BeginOAuth:output_type -> saas.accounts.v1.BeginOAuthResponse - 4, // 23: saas.accounts.v1.AuthService.Authenticate:output_type -> saas.accounts.v1.AuthenticateResponse - 6, // 24: saas.accounts.v1.AuthService.CompleteMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse - 8, // 25: saas.accounts.v1.AuthService.BeginWebAuthnMFAChallenge:output_type -> saas.accounts.v1.BeginWebAuthnMFAChallengeResponse - 6, // 26: saas.accounts.v1.AuthService.CompleteWebAuthnMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse - 11, // 27: saas.accounts.v1.AuthService.RefreshToken:output_type -> saas.accounts.v1.RefreshTokenResponse - 13, // 28: saas.accounts.v1.AuthService.SwitchOrganization:output_type -> saas.accounts.v1.SwitchOrganizationResponse - 15, // 29: saas.accounts.v1.AuthService.Logout:output_type -> saas.accounts.v1.LogoutResponse - 16, // 30: saas.accounts.v1.AuthService.GetJWKS:output_type -> saas.accounts.v1.JWKSResponse - 21, // 31: saas.accounts.v1.AuthService.ValidateClientAuthorization:output_type -> saas.accounts.v1.ValidateClientAuthorizationResponse - 23, // 32: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:output_type -> saas.accounts.v1.IssueClientAuthorizationCodeResponse - 27, // 33: saas.accounts.v1.AuthService.ExchangeClientToken:output_type -> saas.accounts.v1.ExchangeClientTokenResponse - 22, // [22:34] is the sub-list for method output_type - 10, // [10:22] is the sub-list for method input_type + 15, // 18: saas.accounts.v1.AuthService.GetProviderSignOut:input_type -> saas.accounts.v1.GetProviderSignOutRequest + 31, // 19: saas.accounts.v1.AuthService.GetJWKS:input_type -> google.protobuf.Empty + 21, // 20: saas.accounts.v1.AuthService.ValidateClientAuthorization:input_type -> saas.accounts.v1.ValidateClientAuthorizationRequest + 23, // 21: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:input_type -> saas.accounts.v1.IssueClientAuthorizationCodeRequest + 27, // 22: saas.accounts.v1.AuthService.ExchangeClientToken:input_type -> saas.accounts.v1.ExchangeClientTokenRequest + 19, // 23: saas.accounts.v1.AuthService.BeginOAuth:output_type -> saas.accounts.v1.BeginOAuthResponse + 4, // 24: saas.accounts.v1.AuthService.Authenticate:output_type -> saas.accounts.v1.AuthenticateResponse + 6, // 25: saas.accounts.v1.AuthService.CompleteMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse + 8, // 26: saas.accounts.v1.AuthService.BeginWebAuthnMFAChallenge:output_type -> saas.accounts.v1.BeginWebAuthnMFAChallengeResponse + 6, // 27: saas.accounts.v1.AuthService.CompleteWebAuthnMFAChallenge:output_type -> saas.accounts.v1.CompleteMFAChallengeResponse + 11, // 28: saas.accounts.v1.AuthService.RefreshToken:output_type -> saas.accounts.v1.RefreshTokenResponse + 13, // 29: saas.accounts.v1.AuthService.SwitchOrganization:output_type -> saas.accounts.v1.SwitchOrganizationResponse + 31, // 30: saas.accounts.v1.AuthService.Logout:output_type -> google.protobuf.Empty + 16, // 31: saas.accounts.v1.AuthService.GetProviderSignOut:output_type -> saas.accounts.v1.GetProviderSignOutResponse + 17, // 32: saas.accounts.v1.AuthService.GetJWKS:output_type -> saas.accounts.v1.JWKSResponse + 22, // 33: saas.accounts.v1.AuthService.ValidateClientAuthorization:output_type -> saas.accounts.v1.ValidateClientAuthorizationResponse + 24, // 34: saas.accounts.v1.AuthService.IssueClientAuthorizationCode:output_type -> saas.accounts.v1.IssueClientAuthorizationCodeResponse + 28, // 35: saas.accounts.v1.AuthService.ExchangeClientToken:output_type -> saas.accounts.v1.ExchangeClientTokenResponse + 23, // [23:36] is the sub-list for method output_type + 10, // [10:23] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name 10, // [10:10] is the sub-list for extension extendee 0, // [0:10] is the sub-list for field type_name @@ -1966,7 +2014,7 @@ func file_saas_accounts_v1_authentication_proto_init() { (*AuthenticateRequest_Fixture)(nil), (*AuthenticateRequest_HeaderJwt)(nil), } - file_saas_accounts_v1_authentication_proto_msgTypes[26].OneofWrappers = []any{ + file_saas_accounts_v1_authentication_proto_msgTypes[27].OneofWrappers = []any{ (*ExchangeClientTokenRequest_AuthorizationCode)(nil), (*ExchangeClientTokenRequest_RefreshToken)(nil), } @@ -1976,7 +2024,7 @@ func file_saas_accounts_v1_authentication_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_authentication_proto_rawDesc), len(file_saas_accounts_v1_authentication_proto_rawDesc)), NumEnums: 0, - NumMessages: 29, + NumMessages: 30, NumExtensions: 0, NumServices: 1, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.gw.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.gw.go index f74c15249..a5c03b875 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.gw.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication.pb.gw.go @@ -252,6 +252,33 @@ func local_request_AuthService_Logout_0(ctx context.Context, marshaler runtime.M return msg, metadata, err } +func request_AuthService_GetProviderSignOut_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetProviderSignOutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.GetProviderSignOut(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_GetProviderSignOut_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetProviderSignOutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetProviderSignOut(ctx, &protoReq) + return msg, metadata, err +} + func request_AuthService_GetJWKS_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq emptypb.Empty @@ -520,6 +547,26 @@ func RegisterAuthServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux } forward_AuthService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_AuthService_GetProviderSignOut_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/saas.accounts.v1.AuthService/GetProviderSignOut", runtime.WithHTTPPathPattern("/v1/auth/provider-sign-out")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_GetProviderSignOut_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_GetProviderSignOut_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodGet, pattern_AuthService_GetJWKS_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -776,6 +823,23 @@ func RegisterAuthServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux } forward_AuthService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_AuthService_GetProviderSignOut_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/saas.accounts.v1.AuthService/GetProviderSignOut", runtime.WithHTTPPathPattern("/v1/auth/provider-sign-out")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_GetProviderSignOut_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_GetProviderSignOut_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodGet, pattern_AuthService_GetJWKS_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -856,6 +920,7 @@ var ( pattern_AuthService_RefreshToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "auth", "refresh"}, "")) pattern_AuthService_SwitchOrganization_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "auth", "switch-organization"}, "")) pattern_AuthService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "auth", "logout"}, "")) + pattern_AuthService_GetProviderSignOut_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "auth", "provider-sign-out"}, "")) pattern_AuthService_GetJWKS_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "auth", ".well-known", "jwks.json"}, "")) pattern_AuthService_ValidateClientAuthorization_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "auth", "clients", "validate"}, "")) pattern_AuthService_IssueClientAuthorizationCode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v1", "auth", "clients", "authorize"}, "")) @@ -871,6 +936,7 @@ var ( forward_AuthService_RefreshToken_0 = runtime.ForwardResponseMessage forward_AuthService_SwitchOrganization_0 = runtime.ForwardResponseMessage forward_AuthService_Logout_0 = runtime.ForwardResponseMessage + forward_AuthService_GetProviderSignOut_0 = runtime.ForwardResponseMessage forward_AuthService_GetJWKS_0 = runtime.ForwardResponseMessage forward_AuthService_ValidateClientAuthorization_0 = runtime.ForwardResponseMessage forward_AuthService_IssueClientAuthorizationCode_0 = runtime.ForwardResponseMessage diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go index c1fe4093f..7f24b5f66 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/authentication_grpc.pb.go @@ -29,6 +29,7 @@ const ( AuthService_RefreshToken_FullMethodName = "/saas.accounts.v1.AuthService/RefreshToken" AuthService_SwitchOrganization_FullMethodName = "/saas.accounts.v1.AuthService/SwitchOrganization" AuthService_Logout_FullMethodName = "/saas.accounts.v1.AuthService/Logout" + AuthService_GetProviderSignOut_FullMethodName = "/saas.accounts.v1.AuthService/GetProviderSignOut" AuthService_GetJWKS_FullMethodName = "/saas.accounts.v1.AuthService/GetJWKS" AuthService_ValidateClientAuthorization_FullMethodName = "/saas.accounts.v1.AuthService/ValidateClientAuthorization" AuthService_IssueClientAuthorizationCode_FullMethodName = "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode" @@ -59,10 +60,17 @@ type AuthServiceClient interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(ctx context.Context, in *SwitchOrganizationRequest, opts ...grpc.CallOption) (*SwitchOrganizationResponse, error) - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) + Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + GetProviderSignOut(ctx context.Context, in *GetProviderSignOutRequest, opts ...grpc.CallOption) (*GetProviderSignOutResponse, error) GetJWKS(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*JWKSResponse, error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -161,9 +169,9 @@ func (c *authServiceClient) SwitchOrganization(ctx context.Context, in *SwitchOr return out, nil } -func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) { +func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(LogoutResponse) + out := new(emptypb.Empty) err := c.cc.Invoke(ctx, AuthService_Logout_FullMethodName, in, out, cOpts...) if err != nil { return nil, err @@ -171,6 +179,16 @@ func (c *authServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts return out, nil } +func (c *authServiceClient) GetProviderSignOut(ctx context.Context, in *GetProviderSignOutRequest, opts ...grpc.CallOption) (*GetProviderSignOutResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetProviderSignOutResponse) + err := c.cc.Invoke(ctx, AuthService_GetProviderSignOut_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authServiceClient) GetJWKS(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*JWKSResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(JWKSResponse) @@ -235,10 +253,17 @@ type AuthServiceServer interface { // in the same device family. Membership and current role are resolved under // the locked session row before the selected organization is persisted. SwitchOrganization(context.Context, *SwitchOrganizationRequest) (*SwitchOrganizationResponse, error) - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) + Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + GetProviderSignOut(context.Context, *GetProviderSignOutRequest) (*GetProviderSignOutResponse, error) GetJWKS(context.Context, *emptypb.Empty) (*JWKSResponse, error) // ValidateClientAuthorization checks a client's authorization request before // any sign-in UI is rendered. Public RPC: this runs before the person has @@ -288,9 +313,12 @@ func (UnimplementedAuthServiceServer) RefreshToken(context.Context, *RefreshToke func (UnimplementedAuthServiceServer) SwitchOrganization(context.Context, *SwitchOrganizationRequest) (*SwitchOrganizationResponse, error) { return nil, status.Error(codes.Unimplemented, "method SwitchOrganization not implemented") } -func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) { +func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method Logout not implemented") } +func (UnimplementedAuthServiceServer) GetProviderSignOut(context.Context, *GetProviderSignOutRequest) (*GetProviderSignOutResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProviderSignOut not implemented") +} func (UnimplementedAuthServiceServer) GetJWKS(context.Context, *emptypb.Empty) (*JWKSResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetJWKS not implemented") } @@ -468,6 +496,24 @@ func _AuthService_Logout_Handler(srv interface{}, ctx context.Context, dec func( return interceptor(ctx, in, info, handler) } +func _AuthService_GetProviderSignOut_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderSignOutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).GetProviderSignOut(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_GetProviderSignOut_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).GetProviderSignOut(ctx, req.(*GetProviderSignOutRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthService_GetJWKS_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(emptypb.Empty) if err := dec(in); err != nil { @@ -579,6 +625,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Logout", Handler: _AuthService_Logout_Handler, }, + { + MethodName: "GetProviderSignOut", + Handler: _AuthService_GetProviderSignOut_Handler, + }, { MethodName: "GetJWKS", Handler: _AuthService_GetJWKS_Handler, diff --git a/module/services/accounts/generated/api-contract-surfaces.json b/module/services/accounts/generated/api-contract-surfaces.json index 542eb61a1..c376cec98 100644 --- a/module/services/accounts/generated/api-contract-surfaces.json +++ b/module/services/accounts/generated/api-contract-surfaces.json @@ -157,6 +157,7 @@ "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", "/saas.accounts.v1.AuthService/ExchangeClientToken", "/saas.accounts.v1.AuthService/GetJWKS", + "/saas.accounts.v1.AuthService/GetProviderSignOut", "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "/saas.accounts.v1.AuthService/Logout", "/saas.accounts.v1.AuthService/RefreshToken", diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index 49d116537..6e194b14c 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -1836,6 +1836,43 @@ "policy_sha256": "4ab8ff160a72614e5ff4f0e73b3392532b05cfd70c53c44531377aa348cddc5d", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "full_service": "saas.accounts.v1.AuthService", + "method": "GetProviderSignOut", + "input_type": "saas.accounts.v1.GetProviderSignOutRequest", + "output_type": "saas.accounts.v1.GetProviderSignOutResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_PUBLIC", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", + "request_sensitivity": "SENSITIVITY_SECRET", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "public", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "saas/accounts/v1/authentication.proto", + "policy_sha256": "4b5e14d05eaf05e0a219de44c1a72d125aa0a333b1be7d49a0f87e4dfb001f5d", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_CLOSED" + }, { "procedure": "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "full_service": "saas.accounts.v1.AuthService", @@ -1880,7 +1917,7 @@ "full_service": "saas.accounts.v1.AuthService", "method": "Logout", "input_type": "saas.accounts.v1.LogoutRequest", - "output_type": "saas.accounts.v1.LogoutResponse", + "output_type": "google.protobuf.Empty", "client_streaming": false, "server_streaming": false, "policy": { diff --git a/module/services/accounts/generated/gateway-routes.json b/module/services/accounts/generated/gateway-routes.json index 5a558fd21..613919847 100644 --- a/module/services/accounts/generated/gateway-routes.json +++ b/module/services/accounts/generated/gateway-routes.json @@ -273,6 +273,22 @@ "rewrite_path": "/saas.accounts.v1.AuthService/GetJWKS", "remove_after": "2026-10-11" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/customers.AuthService/GetProviderSignOut", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_PUBLIC", + "source": "GATEWAY_ROUTE_SOURCE_COMPATIBILITY_ALIAS", + "rewrite_path": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "remove_after": "2026-10-11" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", @@ -4129,6 +4145,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_PUBLIC", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", @@ -8913,6 +8945,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_REST", + "method": "POST", + "path": "/v1/auth/provider-sign-out", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "rest", + "exposure": "EXPOSURE_PUBLIC", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_REST", "method": "POST", diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 8466556b8..2b9399e47 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -870,13 +870,13 @@ }, "/v1/auth/logout": { "post": { - "summary": "Logout ends the host session and, when the identity provider publishes an\n`end_session_endpoint`, returns the RP-initiated logout URL for the client\nto complete. See module/AUTHENTICATION.md \"Signing out\".", "operationId": "AuthService_Logout", "responses": { "200": { "description": "A successful response.", "schema": { - "$ref": "#/definitions/v1LogoutResponse" + "type": "object", + "properties": {} } }, "default": { @@ -1035,6 +1035,40 @@ ] } }, + "/v1/auth/provider-sign-out": { + "post": { + "summary": "GetProviderSignOut returns the identity provider's RP-initiated logout URL,\nso a client can complete the half of a sign-out the host cannot: ending the\nprovider's own session, which needs the person's browser. Public, like\nGetJWKS, because everything it answers with is already public — the\ndeployment's client id and its registered post-logout redirect — and\nbecause a client needs it on a sign-out that has already ended the session\nwhose bearer it would otherwise present. Empty when this provider offers no\nRP-initiated sign-out. See FEATURES.md \"Signing out of the identity\nprovider\".", + "operationId": "AuthService_GetProviderSignOut", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetProviderSignOutResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "GetProviderSignOutRequest asks for the URL that ends the session at the\nidentity provider. It is a separate call rather than a field on the Logout\nresponse because `saas.accounts.v1` is additive: an RPC's response type is\nnever changed (module/CONTRACT_VERSIONING.md), and every client generated\nagainst `Logout` returning Empty would have to be rebuilt. Asking separately\nalso sequences better — a client can hold the URL before it signs out, so it\nhas somewhere to send the person the moment the host session ends.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1GetProviderSignOutRequest" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, "/v1/auth/refresh": { "post": { "operationId": "AuthService_RefreshToken", @@ -9093,6 +9127,26 @@ } } }, + "v1GetProviderSignOutRequest": { + "type": "object", + "properties": { + "idTokenHint": { + "type": "string", + "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending." + } + }, + "description": "GetProviderSignOutRequest asks for the URL that ends the session at the\nidentity provider. It is a separate call rather than a field on the Logout\nresponse because `saas.accounts.v1` is additive: an RPC's response type is\nnever changed (module/CONTRACT_VERSIONING.md), and every client generated\nagainst `Logout` returning Empty would have to be rebuilt. Asking separately\nalso sequences better — a client can hold the URL before it signs out, so it\nhas somewhere to send the person the moment the host session ends." + }, + "v1GetProviderSignOutResponse": { + "type": "object", + "properties": { + "endSessionUrl": { + "type": "string", + "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to once the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure." + } + }, + "description": "GetProviderSignOutResponse carries what the host cannot do on the person's\nbehalf. The host ends its own session itself; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make." + }, "v1GetSelfResponse": { "type": "object", "properties": { @@ -10600,23 +10654,9 @@ "properties": { "refreshToken": { "type": "string" - }, - "idTokenHint": { - "type": "string", - "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending." } } }, - "v1LogoutResponse": { - "type": "object", - "properties": { - "endSessionUrl": { - "type": "string", - "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to after the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure." - } - }, - "description": "LogoutResponse carries what the host cannot do on the person's behalf. The\nhost ends its own session unconditionally; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make." - }, "v1MFADevice": { "type": "object", "properties": { diff --git a/module/services/accounts/generated/rest-surface.json b/module/services/accounts/generated/rest-surface.json index 870cd47c0..b9e233e45 100644 --- a/module/services/accounts/generated/rest-surface.json +++ b/module/services/accounts/generated/rest-surface.json @@ -1573,6 +1573,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_REST", + "method": "POST", + "path": "/v1/auth/provider-sign-out", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "rest", + "exposure": "EXPOSURE_PUBLIC", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_REST", "method": "POST", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 835fe99d8..531e183b5 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -117,6 +117,7 @@ "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", "/saas.accounts.v1.AuthService/ExchangeClientToken", "/saas.accounts.v1.AuthService/GetJWKS", + "/saas.accounts.v1.AuthService/GetProviderSignOut", "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "/saas.accounts.v1.AuthService/Logout", "/saas.accounts.v1.AuthService/RefreshToken", @@ -2845,6 +2846,57 @@ "service": "accounts" } }, + { + "procedure": "/saas.accounts.v1.AuthService/GetProviderSignOut", + "service": "AuthService", + "full_service": "saas.accounts.v1.AuthService", + "method": "GetProviderSignOut", + "input_type": "saas.accounts.v1.GetProviderSignOutRequest", + "output_type": "saas.accounts.v1.GetProviderSignOutResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "saas/accounts/v1/authentication.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT", + "PROTOCOL_REST" + ], + "http_bindings": [ + { + "method": "POST", + "path": "/v1/auth/provider-sign-out", + "body": "*", + "response_body": "" + } + ], + "policy": { + "exposure": "EXPOSURE_PUBLIC", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_AUTHENTICATION", + "request_sensitivity": "SENSITIVITY_SECRET", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "public", + "description": "The identity provider's RP-initiated logout URL, for a client to end the provider's own session.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "service": "AuthService", @@ -2904,7 +2956,7 @@ "full_service": "saas.accounts.v1.AuthService", "method": "Logout", "input_type": "saas.accounts.v1.LogoutRequest", - "output_type": "saas.accounts.v1.LogoutResponse", + "output_type": "google.protobuf.Empty", "client_streaming": false, "server_streaming": false, "deprecated": false, diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 3eb6431ea..4ea296652 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -2282,6 +2282,26 @@ }, "type": "object" }, + "v1GetProviderSignOutRequest": { + "description": "GetProviderSignOutRequest asks for the URL that ends the session at the\nidentity provider. It is a separate call rather than a field on the Logout\nresponse because `saas.accounts.v1` is additive: an RPC's response type is\nnever changed (module/CONTRACT_VERSIONING.md), and every client generated\nagainst `Logout` returning Empty would have to be rebuilt. Asking separately\nalso sequences better — a client can hold the URL before it signs out, so it\nhas somewhere to send the person the moment the host session ends.", + "properties": { + "idTokenHint": { + "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending.", + "type": "string" + } + }, + "type": "object" + }, + "v1GetProviderSignOutResponse": { + "description": "GetProviderSignOutResponse carries what the host cannot do on the person's\nbehalf. The host ends its own session itself; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make.", + "properties": { + "endSessionUrl": { + "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to once the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure.", + "type": "string" + } + }, + "type": "object" + }, "v1GetSelfResponse": { "properties": { "identities": { @@ -3684,26 +3704,12 @@ }, "v1LogoutRequest": { "properties": { - "idTokenHint": { - "description": "id_token_hint is the provider's id_token for the session being ended, as\nthe client still holds it. It is optional and client-supplied on purpose:\nthe host never retains a provider id_token — it has no other use for one,\nand storing a provider credential to hand back later is a liability, not a\nfeature. When it is absent the end-session URL carries `client_id` instead,\nwhich OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case\nwhere no hint is available. The provider may then ask the person to confirm\nwhich session they are ending.", - "type": "string" - }, "refreshToken": { "type": "string" } }, "type": "object" }, - "v1LogoutResponse": { - "description": "LogoutResponse carries what the host cannot do on the person's behalf. The\nhost ends its own session unconditionally; reaching the identity provider\nrequires the person's own user agent, because the provider's session cookie\nis in their browser and not in any request accounts can make.", - "properties": { - "endSessionUrl": { - "description": "end_session_url is the provider's `end_session_endpoint` with the\nRP-initiated logout parameters already applied, for the client to send the\nperson's browser to after the host session has ended. Empty when this\ndeployment's provider publishes no `end_session_endpoint`, or when the\nconfigured provider has no OIDC sign-out at all (the development fixture):\nan empty value is the whole answer, and a client must treat it as \"nothing\nfurther to do\" rather than as a failure.", - "type": "string" - } - }, - "type": "object" - }, "v1MFADevice": { "properties": { "createdAt": { @@ -7059,7 +7065,8 @@ "200": { "description": "A successful response.", "schema": { - "$ref": "#/definitions/v1LogoutResponse" + "properties": {}, + "type": "object" } }, "default": { @@ -7069,7 +7076,6 @@ } } }, - "summary": "Logout ends the host session and, when the identity provider publishes an\n`end_session_endpoint`, returns the RP-initiated logout URL for the client\nto complete. See module/AUTHENTICATION.md \"Signing out\".", "tags": [ "AuthService" ] @@ -7209,6 +7215,40 @@ ] } }, + "/v1/auth/provider-sign-out": { + "post": { + "operationId": "AuthService_GetProviderSignOut", + "parameters": [ + { + "description": "GetProviderSignOutRequest asks for the URL that ends the session at the\nidentity provider. It is a separate call rather than a field on the Logout\nresponse because `saas.accounts.v1` is additive: an RPC's response type is\nnever changed (module/CONTRACT_VERSIONING.md), and every client generated\nagainst `Logout` returning Empty would have to be rebuilt. Asking separately\nalso sequences better — a client can hold the URL before it signs out, so it\nhas somewhere to send the person the moment the host session ends.", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1GetProviderSignOutRequest" + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetProviderSignOutResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "summary": "GetProviderSignOut returns the identity provider's RP-initiated logout URL,\nso a client can complete the half of a sign-out the host cannot: ending the\nprovider's own session, which needs the person's browser. Public, like\nGetJWKS, because everything it answers with is already public — the\ndeployment's client id and its registered post-logout redirect — and\nbecause a client needs it on a sign-out that has already ended the session\nwhose bearer it would otherwise present. Empty when this provider offers no\nRP-initiated sign-out. See FEATURES.md \"Signing out of the identity\nprovider\".", + "tags": [ + "AuthService" + ] + } + }, "/v1/auth/refresh": { "post": { "operationId": "AuthService_RefreshToken", diff --git a/module/services/accounts/proto/saas/accounts/v1/authentication.proto b/module/services/accounts/proto/saas/accounts/v1/authentication.proto index a9ce38080..a832628bb 100644 --- a/module/services/accounts/proto/saas/accounts/v1/authentication.proto +++ b/module/services/accounts/proto/saas/accounts/v1/authentication.proto @@ -170,7 +170,16 @@ message SwitchOrganizationResponse { message LogoutRequest { string refresh_token = 1 [(buf.validate.field).string.min_len = 1]; +} +// GetProviderSignOutRequest asks for the URL that ends the session at the +// identity provider. It is a separate call rather than a field on the Logout +// response because `saas.accounts.v1` is additive: an RPC's response type is +// never changed (module/CONTRACT_VERSIONING.md), and every client generated +// against `Logout` returning Empty would have to be rebuilt. Asking separately +// also sequences better — a client can hold the URL before it signs out, so it +// has somewhere to send the person the moment the host session ends. +message GetProviderSignOutRequest { // id_token_hint is the provider's id_token for the session being ended, as // the client still holds it. It is optional and client-supplied on purpose: // the host never retains a provider id_token — it has no other use for one, @@ -179,17 +188,17 @@ message LogoutRequest { // which OpenID Connect RP-Initiated Logout 1.0 §2 allows exactly for the case // where no hint is available. The provider may then ask the person to confirm // which session they are ending. - string id_token_hint = 2; + string id_token_hint = 1; } -// LogoutResponse carries what the host cannot do on the person's behalf. The -// host ends its own session unconditionally; reaching the identity provider +// GetProviderSignOutResponse carries what the host cannot do on the person's +// behalf. The host ends its own session itself; reaching the identity provider // requires the person's own user agent, because the provider's session cookie // is in their browser and not in any request accounts can make. -message LogoutResponse { +message GetProviderSignOutResponse { // end_session_url is the provider's `end_session_endpoint` with the // RP-initiated logout parameters already applied, for the client to send the - // person's browser to after the host session has ended. Empty when this + // person's browser to once the host session has ended. Empty when this // deployment's provider publishes no `end_session_endpoint`, or when the // configured provider has no OIDC sign-out at all (the development fixture): // an empty value is the whole answer, and a client must treat it as "nothing @@ -483,10 +492,7 @@ service AuthService { }; } - // Logout ends the host session and, when the identity provider publishes an - // `end_session_endpoint`, returns the RP-initiated logout URL for the client - // to complete. See module/AUTHENTICATION.md "Signing out". - rpc Logout(LogoutRequest) returns (LogoutResponse) { + rpc Logout(LogoutRequest) returns (google.protobuf.Empty) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC tenant: TENANT_REQUIREMENT_NONE @@ -504,6 +510,33 @@ service AuthService { }; } + // GetProviderSignOut returns the identity provider's RP-initiated logout URL, + // so a client can complete the half of a sign-out the host cannot: ending the + // provider's own session, which needs the person's browser. Public, like + // GetJWKS, because everything it answers with is already public — the + // deployment's client id and its registered post-logout redirect — and + // because a client needs it on a sign-out that has already ended the session + // whose bearer it would otherwise present. Empty when this provider offers no + // RP-initiated sign-out. See FEATURES.md "Signing out of the identity + // provider". + rpc GetProviderSignOut(GetProviderSignOutRequest) returns (GetProviderSignOutResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_PUBLIC + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_AUTHENTICATION + request_sensitivity: SENSITIVITY_SECRET + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + option (google.api.http) = { + post: "/v1/auth/provider-sign-out" + body: "*" + }; + } + rpc GetJWKS(google.protobuf.Empty) returns (JWKSResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_PUBLIC diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index 0643fdfda..40d4d9e1b 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -57,6 +57,7 @@ var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadat "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: true, policySHA256: "0f896e43dc4f7b5f23169f798601928d9def9fa9da25b178904872b2e587e952"}, "/saas.accounts.v1.AuthService/ExchangeClientToken": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "99c6b88deef67aac00d037bac65ddca87cd92721d0061203183604bcd8ffef48"}, "/saas.accounts.v1.AuthService/GetJWKS": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassPublic, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "4ab8ff160a72614e5ff4f0e73b3392532b05cfd70c53c44531377aa348cddc5d"}, + "/saas.accounts.v1.AuthService/GetProviderSignOut": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "4b5e14d05eaf05e0a219de44c1a72d125aa0a333b1be7d49a0f87e4dfb001f5d"}, "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "727c5e47fc81954da14b9aa70bc3856c5e63ff61a9bfa226054b01ef6406a314"}, "/saas.accounts.v1.AuthService/Logout": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "4b5e14d05eaf05e0a219de44c1a72d125aa0a333b1be7d49a0f87e4dfb001f5d"}, "/saas.accounts.v1.AuthService/RefreshToken": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassAuthentication, rateLimitBackendFailClosed: true, authenticationFactorAttempt: false, policySHA256: "271a974e851f827d5a0f6f40ffc6944b12b4e6159797a2253faa26132f5a885b"}, @@ -337,6 +338,7 @@ var generatedRESTProcedureByRoute = map[string]string{ "POST /v1/auth/mfa/webauthn/complete": "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", "POST /v1/auth/token": "/saas.accounts.v1.AuthService/ExchangeClientToken", "GET /v1/auth/.well-known/jwks.json": "/saas.accounts.v1.AuthService/GetJWKS", + "POST /v1/auth/provider-sign-out": "/saas.accounts.v1.AuthService/GetProviderSignOut", "POST /v1/auth/clients/authorize": "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", "POST /v1/auth/logout": "/saas.accounts.v1.AuthService/Logout", "POST /v1/auth/refresh": "/saas.accounts.v1.AuthService/RefreshToken", diff --git a/module/services/auth-gateway/code/routing_catalog_gen.go b/module/services/auth-gateway/code/routing_catalog_gen.go index 704e2aa90..33449b270 100644 --- a/module/services/auth-gateway/code/routing_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_catalog_gen.go @@ -23,6 +23,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/CompleteWebAuthnMFAChallenge", UpstreamPath: "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", Procedure: "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/ExchangeClientToken", UpstreamPath: "/saas.accounts.v1.AuthService/ExchangeClientToken", Procedure: "/saas.accounts.v1.AuthService/ExchangeClientToken"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/GetJWKS", UpstreamPath: "/saas.accounts.v1.AuthService/GetJWKS", Procedure: "/saas.accounts.v1.AuthService/GetJWKS"}, + {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/GetProviderSignOut", UpstreamPath: "/saas.accounts.v1.AuthService/GetProviderSignOut", Procedure: "/saas.accounts.v1.AuthService/GetProviderSignOut"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/IssueClientAuthorizationCode", UpstreamPath: "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", Procedure: "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/Logout", UpstreamPath: "/saas.accounts.v1.AuthService/Logout", Procedure: "/saas.accounts.v1.AuthService/Logout"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.AuthService/RefreshToken", UpstreamPath: "/saas.accounts.v1.AuthService/RefreshToken", Procedure: "/saas.accounts.v1.AuthService/RefreshToken"}, @@ -264,6 +265,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/ExchangeClientToken", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/ExchangeClientToken"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/GetJWKS", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/GetJWKS"}, + {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/GetProviderSignOut", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/GetProviderSignOut"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/IssueClientAuthorizationCode"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/Logout", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/Logout"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.AuthService/RefreshToken", UpstreamPath: "", Procedure: "/saas.accounts.v1.AuthService/RefreshToken"}, diff --git a/module/services/auth-gateway/code/routing_rest_catalog_gen.go b/module/services/auth-gateway/code/routing_rest_catalog_gen.go index 7ff0c1f1f..e9e3ad001 100644 --- a/module/services/auth-gateway/code/routing_rest_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_rest_catalog_gen.go @@ -102,6 +102,7 @@ func generatedCatalogRESTRoutes() []*RouteEntry { {Service: "accounts", Method: "POST", Path: "/v1/auth/mfa/webauthn/begin", Procedure: "/saas.accounts.v1.AuthService/BeginWebAuthnMFAChallenge"}, {Service: "accounts", Method: "POST", Path: "/v1/auth/mfa/webauthn/complete", Procedure: "/saas.accounts.v1.AuthService/CompleteWebAuthnMFAChallenge"}, {Service: "accounts", Method: "POST", Path: "/v1/auth/oauth/begin", Procedure: "/saas.accounts.v1.AuthService/BeginOAuth"}, + {Service: "accounts", Method: "POST", Path: "/v1/auth/provider-sign-out", Procedure: "/saas.accounts.v1.AuthService/GetProviderSignOut"}, {Service: "accounts", Method: "POST", Path: "/v1/auth/refresh", Procedure: "/saas.accounts.v1.AuthService/RefreshToken"}, {Service: "accounts", Method: "POST", Path: "/v1/auth/switch-organization", Procedure: "/saas.accounts.v1.AuthService/SwitchOrganization"}, {Service: "accounts", Method: "POST", Path: "/v1/auth/token", Procedure: "/saas.accounts.v1.AuthService/ExchangeClientToken"}, diff --git a/module/services/auth-gateway/code/routing_rest_test.go b/module/services/auth-gateway/code/routing_rest_test.go index 151f68608..0fd04ce53 100644 --- a/module/services/auth-gateway/code/routing_rest_test.go +++ b/module/services/auth-gateway/code/routing_rest_test.go @@ -24,7 +24,18 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { publicCount++ } } - require.Equal(t, 18, publicCount) + // A route becoming public is the thing this number exists to catch, so it + // stays a literal — there is no exhaustive inventory here to derive it from, + // only the spot checks below. It moved 18 → 19 for exactly one route, and + // that route is named immediately after, so the figure is anchored to a + // claim rather than floating free. + require.Equal(t, 19, publicCount) + // GetProviderSignOut: public like the JWKS, because everything it answers + // with is already public (the deployment's client id, its registered + // post-logout redirect), and because a client asks for it around a sign-out + // — when the bearer it would otherwise present is the one just revoked. + require.NotNil(t, paths["POST /v1/auth/provider-sign-out"]) + require.False(t, paths["POST /v1/auth/provider-sign-out"].Protected) require.NotNil(t, paths["GET /v1/.well-known/service-info"]) require.NotNil(t, paths["GET /v1/public/plans"]) require.False(t, paths["GET /v1/public/plans"].Protected) diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 3eb655220..0335d767d 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:25f2f0c28b6fa087740ce88ac3be323d6306f08911dd4cdafd4aa6835bd91ca0 + contract-digest: sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade services: - AccessibleScopeService - AuditService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts index b2a7eff4e..302bafaa2 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/authentication_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/authentication.proto. */ export const file_saas_accounts_v1_authentication: GenFile = /*@__PURE__*/ - fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiRgoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABEhUKDWlkX3Rva2VuX2hpbnQYAiABKAkiKQoOTG9nb3V0UmVzcG9uc2USFwoPZW5kX3Nlc3Npb25fdXJsGAEgASgJIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMq8SCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SfwoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GiAuc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXNwb25zZSIywvMYFAgBEAEwAToCEAFAAUgCUARYA2ABgtPkkwIUOgEqIg8vdjEvYXV0aC9sb2dvdXQSgQEKB0dldEpXS1MSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHi5zYWFzLmFjY291bnRzLnYxLkpXS1NSZXNwb25zZSI+wvMYFAgBEAEwAToCEAFAAUgBUAFYAWABgtPkkwIgEh4vdjEvYXV0aC8ud2VsbC1rbm93bi9qd2tzLmpzb24SyAEKG1ZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2UiPMLzGBQIARABMAE6AhABQAFIAlADWANgAYLT5JMCHjoBKiIZL3YxL2F1dGgvY2xpZW50cy92YWxpZGF0ZRLpAQocSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZRI1LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZSJawvMYMQgCEAEwATofChtzYWFzLmF1dGguY2xpZW50X2F1dGhvcml6ZWQQAkABSAVQA1gEYAGC0+STAh86ASoiGi92MS9hdXRoL2NsaWVudHMvYXV0aG9yaXplErYBChNFeGNoYW5nZUNsaWVudFRva2VuEiwuc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBotLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlIkLC8xglCAEQATABOhMKD3NhYXMuYXV0aC5sb2dpbhACQAFIAlAEWARgAYLT5JMCEzoBKiIOL3YxL2F1dGgvdG9rZW5iBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiLwoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIjIKGUdldFByb3ZpZGVyU2lnbk91dFJlcXVlc3QSFQoNaWRfdG9rZW5faGludBgBIAEoCSI1ChpHZXRQcm92aWRlclNpZ25PdXRSZXNwb25zZRIXCg9lbmRfc2Vzc2lvbl91cmwYASABKAkiIQoMSldLU1Jlc3BvbnNlEhEKCWtleXNfanNvbhgBIAEoCSJNChFCZWdpbk9BdXRoUmVxdWVzdBIZCghwcm92aWRlchgBIAEoCUIHukgEcgIQARIdCgxyZWRpcmVjdF91cmkYAiABKAlCB7pIBHICEAEiIwoSQmVnaW5PQXV0aFJlc3BvbnNlEg0KBXN0YXRlGAEgASgJItUBChpDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBI2Cg5jb2RlX2NoYWxsZW5nZRgDIAEoCUIeukgbchkQKxiAATISXltBLVphLXowLTkuX34tXSskEigKFWNvZGVfY2hhbGxlbmdlX21ldGhvZBgEIAEoCUIJukgGcgQQARgQInEKIlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASI6CiNWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXNwb25zZRITCgtjbGllbnRfbmFtZRgBIAEoCSJyCiNJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVxdWVzdBJLCg1hdXRob3JpemF0aW9uGAEgASgLMiwuc2Fhcy5hY2NvdW50cy52MS5DbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdEIGukgDyAEBIkgKJElzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZRIMCgRjb2RlGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiiwEKFkF1dGhvcml6YXRpb25Db2RlR3JhbnQSGAoEY29kZRgBIAEoCUIKukgHcgUQARiABBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNQoNY29kZV92ZXJpZmllchgDIAEoCUIeukgbchkQKxiAATISXltBLVphLXowLTkuX34tXSskIjkKF0NsaWVudFJlZnJlc2hUb2tlbkdyYW50Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEi7QEKGkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXF1ZXN0EjMKCWNsaWVudF9pZBgBIAEoCUIgukgdchsQAhhAMhVeW2EtejAtOV1bYS16MC05Xy1dKiQSRgoSYXV0aG9yaXphdGlvbl9jb2RlGAIgASgLMiguc2Fhcy5hY2NvdW50cy52MS5BdXRob3JpemF0aW9uQ29kZUdyYW50SAASQgoNcmVmcmVzaF90b2tlbhgDIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50UmVmcmVzaFRva2VuR3JhbnRIAEIOCgVncmFudBIFukgCCAEiXgobRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMy1hMKC0F1dGhTZXJ2aWNlEpABCgpCZWdpbk9BdXRoEiMuc2Fhcy5hY2NvdW50cy52MS5CZWdpbk9BdXRoUmVxdWVzdBokLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlc3BvbnNlIjfC8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAhk6ASoiFC92MS9hdXRoL29hdXRoL2JlZ2luEskBCgxBdXRoZW50aWNhdGUSJS5zYWFzLmFjY291bnRzLnYxLkF1dGhlbnRpY2F0ZVJlcXVlc3QaJi5zYWFzLmFjY291bnRzLnYxLkF1dGhlbnRpY2F0ZVJlc3BvbnNlImrC8xhGCAEQATABOjQKD3NhYXMuYXV0aC5sb2dpbgofc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2Vfc3RhcnRlZBACQAFIAlAEWARgAYLT5JMCGjoBKiIVL3YxL2F1dGgvYXV0aGVudGljYXRlEoECChRDb21wbGV0ZU1GQUNoYWxsZW5nZRItLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXF1ZXN0Gi4uc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlIokBwvMYZQgBEAEwATpRCg9zYWFzLmF1dGgubG9naW4KIXNhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX2NvbXBsZXRlZAoZc2Fhcy5tZmEuYmFja3VwX2NvZGVfdXNlZBACQAFIAlAEWARgAWgBgtPkkwIaOgEqIhUvdjEvYXV0aC9tZmEvY29tcGxldGUSxAEKGUJlZ2luV2ViQXV0aG5NRkFDaGFsbGVuZ2USMi5zYWFzLmFjY291bnRzLnYxLkJlZ2luV2ViQXV0aG5NRkFDaGFsbGVuZ2VSZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVzcG9uc2UiPsLzGBQIARABMAE6AhABQAFIAlADWARgAYLT5JMCIDoBKiIbL3YxL2F1dGgvbWZhL3dlYmF1dGhuL2JlZ2luEpcCChxDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlEjUuc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKPAcLzGGIIARABMAE6TgoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKFnNhYXMubWZhLndlYmF1dGhuX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCIzoBKiIeL3YxL2F1dGgvbWZhL3dlYmF1dGhuL2NvbXBsZXRlEpIBCgxSZWZyZXNoVG9rZW4SJS5zYWFzLmFjY291bnRzLnYxLlJlZnJlc2hUb2tlblJlcXVlc3QaJi5zYWFzLmFjY291bnRzLnYxLlJlZnJlc2hUb2tlblJlc3BvbnNlIjPC8xgUCAEQATABOgIQAUABSAJQBFgEYAGC0+STAhU6ASoiEC92MS9hdXRoL3JlZnJlc2gS0QEKElN3aXRjaE9yZ2FuaXphdGlvbhIrLnNhYXMuYWNjb3VudHMudjEuU3dpdGNoT3JnYW5pemF0aW9uUmVxdWVzdBosLnNhYXMuYWNjb3VudHMudjEuU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2UiYMLzGDUIAhABMAE6Iwofc2Fhcy5hdXRoLm9yZ2FuaXphdGlvbl9zd2l0Y2hlZBACQAFIBVADWARgAYLT5JMCIToBKiIcL3YxL2F1dGgvc3dpdGNoLW9yZ2FuaXphdGlvbhJ1CgZMb2dvdXQSHy5zYWFzLmFjY291bnRzLnYxLkxvZ291dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMsLzGBQIARABMAE6AhABQAFIAlAEWANgAYLT5JMCFDoBKiIPL3YxL2F1dGgvbG9nb3V0Eq4BChJHZXRQcm92aWRlclNpZ25PdXQSKy5zYWFzLmFjY291bnRzLnYxLkdldFByb3ZpZGVyU2lnbk91dFJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLkdldFByb3ZpZGVyU2lnbk91dFJlc3BvbnNlIj3C8xgUCAEQATABOgIQAUABSAJQBFgDYAGC0+STAh86ASoiGi92MS9hdXRoL3Byb3ZpZGVyLXNpZ24tb3V0EoEBCgdHZXRKV0tTEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5Gh4uc2Fhcy5hY2NvdW50cy52MS5KV0tTUmVzcG9uc2UiPsLzGBQIARABMAE6AhABQAFIAVABWAFgAYLT5JMCIBIeL3YxL2F1dGgvLndlbGwta25vd24vandrcy5qc29uEsgBChtWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb24SNC5zYWFzLmFjY291bnRzLnYxLlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QaNS5zYWFzLmFjY291bnRzLnYxLlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlc3BvbnNlIjzC8xgUCAEQATABOgIQAUABSAJQA1gDYAGC0+STAh46ASoiGS92MS9hdXRoL2NsaWVudHMvdmFsaWRhdGUS6QEKHElzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGUSNS5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2UiWsLzGDEIAhABMAE6Hwobc2Fhcy5hdXRoLmNsaWVudF9hdXRob3JpemVkEAJAAUgFUANYBGABgtPkkwIfOgEqIhovdjEvYXV0aC9jbGllbnRzL2F1dGhvcml6ZRK2AQoTRXhjaGFuZ2VDbGllbnRUb2tlbhIsLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlcXVlc3QaLS5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZSJCwvMYJQgBEAEwAToTCg9zYWFzLmF1dGgubG9naW4QAkABSAJQBFgEYAGC0+STAhM6ASoiDi92MS9hdXRoL3Rva2VuYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.OAuthCodeAuthentication @@ -449,7 +449,27 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * @generated from field: string refresh_token = 1; */ refreshToken: string; +}; + +/** + * Describes the message saas.accounts.v1.LogoutRequest. + * Use `create(LogoutRequestSchema)` to create a new message. + */ +export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 14); +/** + * GetProviderSignOutRequest asks for the URL that ends the session at the + * identity provider. It is a separate call rather than a field on the Logout + * response because `saas.accounts.v1` is additive: an RPC's response type is + * never changed (module/CONTRACT_VERSIONING.md), and every client generated + * against `Logout` returning Empty would have to be rebuilt. Asking separately + * also sequences better — a client can hold the URL before it signs out, so it + * has somewhere to send the person the moment the host session ends. + * + * @generated from message saas.accounts.v1.GetProviderSignOutRequest + */ +export type GetProviderSignOutRequest = Message<"saas.accounts.v1.GetProviderSignOutRequest"> & { /** * id_token_hint is the provider's id_token for the session being ended, as * the client still holds it. It is optional and client-supplied on purpose: @@ -460,31 +480,31 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * where no hint is available. The provider may then ask the person to confirm * which session they are ending. * - * @generated from field: string id_token_hint = 2; + * @generated from field: string id_token_hint = 1; */ idTokenHint: string; }; /** - * Describes the message saas.accounts.v1.LogoutRequest. - * Use `create(LogoutRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.GetProviderSignOutRequest. + * Use `create(GetProviderSignOutRequestSchema)` to create a new message. */ -export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 14); +export const GetProviderSignOutRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 15); /** - * LogoutResponse carries what the host cannot do on the person's behalf. The - * host ends its own session unconditionally; reaching the identity provider + * GetProviderSignOutResponse carries what the host cannot do on the person's + * behalf. The host ends its own session itself; reaching the identity provider * requires the person's own user agent, because the provider's session cookie * is in their browser and not in any request accounts can make. * - * @generated from message saas.accounts.v1.LogoutResponse + * @generated from message saas.accounts.v1.GetProviderSignOutResponse */ -export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { +export type GetProviderSignOutResponse = Message<"saas.accounts.v1.GetProviderSignOutResponse"> & { /** * end_session_url is the provider's `end_session_endpoint` with the * RP-initiated logout parameters already applied, for the client to send the - * person's browser to after the host session has ended. Empty when this + * person's browser to once the host session has ended. Empty when this * deployment's provider publishes no `end_session_endpoint`, or when the * configured provider has no OIDC sign-out at all (the development fixture): * an empty value is the whole answer, and a client must treat it as "nothing @@ -496,11 +516,11 @@ export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { }; /** - * Describes the message saas.accounts.v1.LogoutResponse. - * Use `create(LogoutResponseSchema)` to create a new message. + * Describes the message saas.accounts.v1.GetProviderSignOutResponse. + * Use `create(GetProviderSignOutResponseSchema)` to create a new message. */ -export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 15); +export const GetProviderSignOutResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 16); /** * @generated from message saas.accounts.v1.JWKSResponse @@ -517,7 +537,7 @@ export type JWKSResponse = Message<"saas.accounts.v1.JWKSResponse"> & { * Use `create(JWKSResponseSchema)` to create a new message. */ export const JWKSResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 16); + messageDesc(file_saas_accounts_v1_authentication, 17); /** * BeginOAuthRequest is the FE's first hop on the authorization-code @@ -545,7 +565,7 @@ export type BeginOAuthRequest = Message<"saas.accounts.v1.BeginOAuthRequest"> & * Use `create(BeginOAuthRequestSchema)` to create a new message. */ export const BeginOAuthRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 17); + messageDesc(file_saas_accounts_v1_authentication, 18); /** * @generated from message saas.accounts.v1.BeginOAuthResponse @@ -565,7 +585,7 @@ export type BeginOAuthResponse = Message<"saas.accounts.v1.BeginOAuthResponse"> * Use `create(BeginOAuthResponseSchema)` to create a new message. */ export const BeginOAuthResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 18); + messageDesc(file_saas_accounts_v1_authentication, 19); /** * ClientAuthorizationRequest is the authorization request a client encodes into @@ -606,7 +626,7 @@ export type ClientAuthorizationRequest = Message<"saas.accounts.v1.ClientAuthori * Use `create(ClientAuthorizationRequestSchema)` to create a new message. */ export const ClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 19); + messageDesc(file_saas_accounts_v1_authentication, 20); /** * @generated from message saas.accounts.v1.ValidateClientAuthorizationRequest @@ -623,7 +643,7 @@ export type ValidateClientAuthorizationRequest = Message<"saas.accounts.v1.Valid * Use `create(ValidateClientAuthorizationRequestSchema)` to create a new message. */ export const ValidateClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 20); + messageDesc(file_saas_accounts_v1_authentication, 21); /** * ValidateClientAuthorizationResponse carries only what the login page needs to @@ -644,7 +664,7 @@ export type ValidateClientAuthorizationResponse = Message<"saas.accounts.v1.Vali * Use `create(ValidateClientAuthorizationResponseSchema)` to create a new message. */ export const ValidateClientAuthorizationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 21); + messageDesc(file_saas_accounts_v1_authentication, 22); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeRequest @@ -661,7 +681,7 @@ export type IssueClientAuthorizationCodeRequest = Message<"saas.accounts.v1.Issu * Use `create(IssueClientAuthorizationCodeRequestSchema)` to create a new message. */ export const IssueClientAuthorizationCodeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 22); + messageDesc(file_saas_accounts_v1_authentication, 23); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeResponse @@ -686,7 +706,7 @@ export type IssueClientAuthorizationCodeResponse = Message<"saas.accounts.v1.Iss * Use `create(IssueClientAuthorizationCodeResponseSchema)` to create a new message. */ export const IssueClientAuthorizationCodeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 23); + messageDesc(file_saas_accounts_v1_authentication, 24); /** * AuthorizationCodeGrant redeems a code issued by IssueClientAuthorizationCode. @@ -717,7 +737,7 @@ export type AuthorizationCodeGrant = Message<"saas.accounts.v1.AuthorizationCode * Use `create(AuthorizationCodeGrantSchema)` to create a new message. */ export const AuthorizationCodeGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 24); + messageDesc(file_saas_accounts_v1_authentication, 25); /** * ClientRefreshTokenGrant rotates a client's own refresh token. The presented @@ -738,7 +758,7 @@ export type ClientRefreshTokenGrant = Message<"saas.accounts.v1.ClientRefreshTok * Use `create(ClientRefreshTokenGrantSchema)` to create a new message. */ export const ClientRefreshTokenGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 25); + messageDesc(file_saas_accounts_v1_authentication, 26); /** * @generated from message saas.accounts.v1.ExchangeClientTokenRequest @@ -772,7 +792,7 @@ export type ExchangeClientTokenRequest = Message<"saas.accounts.v1.ExchangeClien * Use `create(ExchangeClientTokenRequestSchema)` to create a new message. */ export const ExchangeClientTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 26); + messageDesc(file_saas_accounts_v1_authentication, 27); /** * ExchangeClientTokenResponse returns both halves in the body. A public client @@ -803,7 +823,7 @@ export type ExchangeClientTokenResponse = Message<"saas.accounts.v1.ExchangeClie * Use `create(ExchangeClientTokenResponseSchema)` to create a new message. */ export const ExchangeClientTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 27); + messageDesc(file_saas_accounts_v1_authentication, 28); /** * AuthService — JWT token issuance and session management @@ -883,16 +903,30 @@ export const AuthService: GenService<{ output: typeof SwitchOrganizationResponseSchema; }, /** - * Logout ends the host session and, when the identity provider publishes an - * `end_session_endpoint`, returns the RP-initiated logout URL for the client - * to complete. See module/AUTHENTICATION.md "Signing out". - * * @generated from rpc saas.accounts.v1.AuthService.Logout */ logout: { methodKind: "unary"; input: typeof LogoutRequestSchema; - output: typeof LogoutResponseSchema; + output: typeof EmptySchema; + }, + /** + * GetProviderSignOut returns the identity provider's RP-initiated logout URL, + * so a client can complete the half of a sign-out the host cannot: ending the + * provider's own session, which needs the person's browser. Public, like + * GetJWKS, because everything it answers with is already public — the + * deployment's client id and its registered post-logout redirect — and + * because a client needs it on a sign-out that has already ended the session + * whose bearer it would otherwise present. Empty when this provider offers no + * RP-initiated sign-out. See FEATURES.md "Signing out of the identity + * provider". + * + * @generated from rpc saas.accounts.v1.AuthService.GetProviderSignOut + */ + getProviderSignOut: { + methodKind: "unary"; + input: typeof GetProviderSignOutRequestSchema; + output: typeof GetProviderSignOutResponseSchema; }, /** * @generated from rpc saas.accounts.v1.AuthService.GetJWKS diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts index ef7204614..45dcefb12 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/authentication_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/authentication.proto. */ export const file_saas_accounts_v1_authentication: GenFile = /*@__PURE__*/ - fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiRgoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABEhUKDWlkX3Rva2VuX2hpbnQYAiABKAkiKQoOTG9nb3V0UmVzcG9uc2USFwoPZW5kX3Nlc3Npb25fdXJsGAEgASgJIiEKDEpXS1NSZXNwb25zZRIRCglrZXlzX2pzb24YASABKAkiTQoRQmVnaW5PQXV0aFJlcXVlc3QSGQoIcHJvdmlkZXIYASABKAlCB7pIBHICEAESHQoMcmVkaXJlY3RfdXJpGAIgASgJQge6SARyAhABIiMKEkJlZ2luT0F1dGhSZXNwb25zZRINCgVzdGF0ZRgBIAEoCSLVAQoaQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSMwoJY2xpZW50X2lkGAEgASgJQiC6SB1yGxACGEAyFV5bYS16MC05XVthLXowLTlfLV0qJBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNgoOY29kZV9jaGFsbGVuZ2UYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJBIoChVjb2RlX2NoYWxsZW5nZV9tZXRob2QYBCABKAlCCbpIBnIEEAEYECJxCiJWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0EksKDWF1dGhvcml6YXRpb24YASABKAsyLC5zYWFzLmFjY291bnRzLnYxLkNsaWVudEF1dGhvcml6YXRpb25SZXF1ZXN0Qga6SAPIAQEiOgojVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2USEwoLY2xpZW50X25hbWUYASABKAkicgojSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASJICiRJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2USDAoEY29kZRgBIAEoCRISCgpleHBpcmVzX2luGAIgASgDIosBChZBdXRob3JpemF0aW9uQ29kZUdyYW50EhgKBGNvZGUYASABKAlCCrpIB3IFEAEYgAQSIAoMcmVkaXJlY3RfdXJpGAIgASgJQgq6SAdyBRABGIAQEjUKDWNvZGVfdmVyaWZpZXIYAyABKAlCHrpIG3IZECsYgAEyEl5bQS1aYS16MC05Ll9+LV0rJCI5ChdDbGllbnRSZWZyZXNoVG9rZW5HcmFudBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIu0BChpFeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEkYKEmF1dGhvcml6YXRpb25fY29kZRgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXphdGlvbkNvZGVHcmFudEgAEkIKDXJlZnJlc2hfdG9rZW4YAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkNsaWVudFJlZnJlc2hUb2tlbkdyYW50SABCDgoFZ3JhbnQSBbpIAggBIl4KG0V4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZRIUCgxhY2Nlc3NfdG9rZW4YASABKAkSFQoNcmVmcmVzaF90b2tlbhgCIAEoCRISCgpleHBpcmVzX2luGAMgASgDMq8SCgtBdXRoU2VydmljZRKQAQoKQmVnaW5PQXV0aBIjLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLkJlZ2luT0F1dGhSZXNwb25zZSI3wvMYFAgBEAEwAToCEAFAAUgCUANYBGABgtPkkwIZOgEqIhQvdjEvYXV0aC9vYXV0aC9iZWdpbhLJAQoMQXV0aGVudGljYXRlEiUuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5BdXRoZW50aWNhdGVSZXNwb25zZSJqwvMYRggBEAEwATo0Cg9zYWFzLmF1dGgubG9naW4KH3NhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX3N0YXJ0ZWQQAkABSAJQBFgEYAGC0+STAho6ASoiFS92MS9hdXRoL2F1dGhlbnRpY2F0ZRKBAgoUQ29tcGxldGVNRkFDaGFsbGVuZ2USLS5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKJAcLzGGUIARABMAE6UQoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKGXNhYXMubWZhLmJhY2t1cF9jb2RlX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCGjoBKiIVL3YxL2F1dGgvbWZhL2NvbXBsZXRlEsQBChlCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlEjIuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlIj7C8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAiA6ASoiGy92MS9hdXRoL21mYS93ZWJhdXRobi9iZWdpbhKXAgocQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZRI1LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVXZWJBdXRobk1GQUNoYWxsZW5nZVJlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlTUZBQ2hhbGxlbmdlUmVzcG9uc2UijwHC8xhiCAEQATABOk4KD3NhYXMuYXV0aC5sb2dpbgohc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2VfY29tcGxldGVkChZzYWFzLm1mYS53ZWJhdXRobl91c2VkEAJAAUgCUARYBGABaAGC0+STAiM6ASoiHi92MS9hdXRoL21mYS93ZWJhdXRobi9jb21wbGV0ZRKSAQoMUmVmcmVzaFRva2VuEiUuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5SZWZyZXNoVG9rZW5SZXNwb25zZSIzwvMYFAgBEAEwAToCEAFAAUgCUARYBGABgtPkkwIVOgEqIhAvdjEvYXV0aC9yZWZyZXNoEtEBChJTd2l0Y2hPcmdhbml6YXRpb24SKy5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLlN3aXRjaE9yZ2FuaXphdGlvblJlc3BvbnNlImDC8xg1CAIQATABOiMKH3NhYXMuYXV0aC5vcmdhbml6YXRpb25fc3dpdGNoZWQQAkABSAVQA1gEYAGC0+STAiE6ASoiHC92MS9hdXRoL3N3aXRjaC1vcmdhbml6YXRpb24SfwoGTG9nb3V0Eh8uc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXF1ZXN0GiAuc2Fhcy5hY2NvdW50cy52MS5Mb2dvdXRSZXNwb25zZSIywvMYFAgBEAEwAToCEAFAAUgCUARYA2ABgtPkkwIUOgEqIg8vdjEvYXV0aC9sb2dvdXQSgQEKB0dldEpXS1MSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHi5zYWFzLmFjY291bnRzLnYxLkpXS1NSZXNwb25zZSI+wvMYFAgBEAEwAToCEAFAAUgBUAFYAWABgtPkkwIgEh4vdjEvYXV0aC8ud2VsbC1rbm93bi9qd2tzLmpzb24SyAEKG1ZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuVmFsaWRhdGVDbGllbnRBdXRob3JpemF0aW9uUmVzcG9uc2UiPMLzGBQIARABMAE6AhABQAFIAlADWANgAYLT5JMCHjoBKiIZL3YxL2F1dGgvY2xpZW50cy92YWxpZGF0ZRLpAQocSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZRI1LnNhYXMuYWNjb3VudHMudjEuSXNzdWVDbGllbnRBdXRob3JpemF0aW9uQ29kZVJlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZSJawvMYMQgCEAEwATofChtzYWFzLmF1dGguY2xpZW50X2F1dGhvcml6ZWQQAkABSAVQA1gEYAGC0+STAh86ASoiGi92MS9hdXRoL2NsaWVudHMvYXV0aG9yaXplErYBChNFeGNoYW5nZUNsaWVudFRva2VuEiwuc2Fhcy5hY2NvdW50cy52MS5FeGNoYW5nZUNsaWVudFRva2VuUmVxdWVzdBotLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlIkLC8xglCAEQATABOhMKD3NhYXMuYXV0aC5sb2dpbhACQAFIAlAEWARgAYLT5JMCEzoBKiIOL3YxL2F1dGgvdG9rZW5CuwEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhNBdXRoZW50aWNhdGlvblByb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); + fileDesc("CiVzYWFzL2FjY291bnRzL3YxL2F1dGhlbnRpY2F0aW9uLnByb3RvEhBzYWFzLmFjY291bnRzLnYxIqcBChdPQXV0aENvZGVBdXRoZW50aWNhdGlvbhIYCgRjb2RlGAEgASgJQgq6SAdyBRABGIAgEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBIZCgVzdGF0ZRgDIAEoCUIKukgHcgUQARiAIBI1Cg1jb2RlX3ZlcmlmaWVyGAQgASgJQh66SBtyGRArGIABMhJeW0EtWmEtejAtOS5ffi1dKyQiMgoVRml4dHVyZUF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGP8BIjQKF0hlYWRlckpXVEF1dGhlbnRpY2F0aW9uEhkKBXRva2VuGAEgASgJQgq6SAdyBRABGIBAIoAEChNBdXRoZW50aWNhdGVSZXF1ZXN0Ei0KCHByb3ZpZGVyGAEgASgJQhu6SBhyFhABGDIyEF5bYS16QS1aMC05Xy1dKyQSFwoLcHJvdmlkZXJfaWQYAiABKAlCAhgBEhoKDnByb3ZpZGVyX2VtYWlsGAMgASgJQgIYARIaCg5lbWFpbF92ZXJpZmllZBgEIAEoCEICGAESQwoHcHJvZmlsZRgFIAMoCzIyLnNhYXMuYWNjb3VudHMudjEuQXV0aGVudGljYXRlUmVxdWVzdC5Qcm9maWxlRW50cnkSHQoLZGV2aWNlX2luZm8YBiABKAlCCLpIBXIDGIAEEj8KCm9hdXRoX2NvZGUYByABKAsyKS5zYWFzLmFjY291bnRzLnYxLk9BdXRoQ29kZUF1dGhlbnRpY2F0aW9uSAASOgoHZml4dHVyZRgIIAEoCzInLnNhYXMuYWNjb3VudHMudjEuRml4dHVyZUF1dGhlbnRpY2F0aW9uSAASPwoKaGVhZGVyX2p3dBgJIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuSGVhZGVySldUQXV0aGVudGljYXRpb25IABouCgxQcm9maWxlRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIXCg5hdXRoZW50aWNhdGlvbhIFukgCCAEipgEKFEF1dGhlbnRpY2F0ZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlchIUCgxtZmFfcmVxdWlyZWQYBSABKAgSEQoJbWZhX3Rva2VuGAYgASgJIlUKG0NvbXBsZXRlTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESFwoEY29kZRgCIAEoCUIJukgGcgQQBhhAIoUBChxDb21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMSJAoEdXNlchgEIAEoCzIWLnNhYXMuYWNjb3VudHMudjEuVXNlciJBCiBCZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wEiXAohQmVnaW5XZWJBdXRobk1GQUNoYWxsZW5nZVJlc3BvbnNlEhYKDmNlcmVtb255X3Rva2VuGAEgASgJEh8KF3B1YmxpY19rZXlfb3B0aW9uc19qc29uGAIgASgJIpcBCiNDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBIdCgltZmFfdG9rZW4YASABKAlCCrpIB3IFECAY/wESIgoOY2VyZW1vbnlfdG9rZW4YAiABKAlCCrpIB3IFECAY/wESLQoYY3JlZGVudGlhbF9yZXNwb25zZV9qc29uGAMgASgJQgu6SAhyBhACGICACCI1ChNSZWZyZXNoVG9rZW5SZXF1ZXN0Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEiVwoUUmVmcmVzaFRva2VuUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhUKDXJlZnJlc2hfdG9rZW4YAiABKAkSEgoKZXhwaXJlc19pbhgDIAEoAyI+ChlTd2l0Y2hPcmdhbml6YXRpb25SZXF1ZXN0EiEKD29yZ2FuaXphdGlvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoaU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2USFAoMYWNjZXNzX3Rva2VuGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiLwoNTG9nb3V0UmVxdWVzdBIeCg1yZWZyZXNoX3Rva2VuGAEgASgJQge6SARyAhABIjIKGUdldFByb3ZpZGVyU2lnbk91dFJlcXVlc3QSFQoNaWRfdG9rZW5faGludBgBIAEoCSI1ChpHZXRQcm92aWRlclNpZ25PdXRSZXNwb25zZRIXCg9lbmRfc2Vzc2lvbl91cmwYASABKAkiIQoMSldLU1Jlc3BvbnNlEhEKCWtleXNfanNvbhgBIAEoCSJNChFCZWdpbk9BdXRoUmVxdWVzdBIZCghwcm92aWRlchgBIAEoCUIHukgEcgIQARIdCgxyZWRpcmVjdF91cmkYAiABKAlCB7pIBHICEAEiIwoSQmVnaW5PQXV0aFJlc3BvbnNlEg0KBXN0YXRlGAEgASgJItUBChpDbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdBIzCgljbGllbnRfaWQYASABKAlCILpIHXIbEAIYQDIVXlthLXowLTldW2EtejAtOV8tXSokEiAKDHJlZGlyZWN0X3VyaRgCIAEoCUIKukgHcgUQARiAEBI2Cg5jb2RlX2NoYWxsZW5nZRgDIAEoCUIeukgbchkQKxiAATISXltBLVphLXowLTkuX34tXSskEigKFWNvZGVfY2hhbGxlbmdlX21ldGhvZBgEIAEoCUIJukgGcgQQARgQInEKIlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QSSwoNYXV0aG9yaXphdGlvbhgBIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3RCBrpIA8gBASI6CiNWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb25SZXNwb25zZRITCgtjbGllbnRfbmFtZRgBIAEoCSJyCiNJc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVxdWVzdBJLCg1hdXRob3JpemF0aW9uGAEgASgLMiwuc2Fhcy5hY2NvdW50cy52MS5DbGllbnRBdXRob3JpemF0aW9uUmVxdWVzdEIGukgDyAEBIkgKJElzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXNwb25zZRIMCgRjb2RlGAEgASgJEhIKCmV4cGlyZXNfaW4YAiABKAMiiwEKFkF1dGhvcml6YXRpb25Db2RlR3JhbnQSGAoEY29kZRgBIAEoCUIKukgHcgUQARiABBIgCgxyZWRpcmVjdF91cmkYAiABKAlCCrpIB3IFEAEYgBASNQoNY29kZV92ZXJpZmllchgDIAEoCUIeukgbchkQKxiAATISXltBLVphLXowLTkuX34tXSskIjkKF0NsaWVudFJlZnJlc2hUb2tlbkdyYW50Eh4KDXJlZnJlc2hfdG9rZW4YASABKAlCB7pIBHICEAEi7QEKGkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXF1ZXN0EjMKCWNsaWVudF9pZBgBIAEoCUIgukgdchsQAhhAMhVeW2EtejAtOV1bYS16MC05Xy1dKiQSRgoSYXV0aG9yaXphdGlvbl9jb2RlGAIgASgLMiguc2Fhcy5hY2NvdW50cy52MS5BdXRob3JpemF0aW9uQ29kZUdyYW50SAASQgoNcmVmcmVzaF90b2tlbhgDIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ2xpZW50UmVmcmVzaFRva2VuR3JhbnRIAEIOCgVncmFudBIFukgCCAEiXgobRXhjaGFuZ2VDbGllbnRUb2tlblJlc3BvbnNlEhQKDGFjY2Vzc190b2tlbhgBIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAIgASgJEhIKCmV4cGlyZXNfaW4YAyABKAMy1hMKC0F1dGhTZXJ2aWNlEpABCgpCZWdpbk9BdXRoEiMuc2Fhcy5hY2NvdW50cy52MS5CZWdpbk9BdXRoUmVxdWVzdBokLnNhYXMuYWNjb3VudHMudjEuQmVnaW5PQXV0aFJlc3BvbnNlIjfC8xgUCAEQATABOgIQAUABSAJQA1gEYAGC0+STAhk6ASoiFC92MS9hdXRoL29hdXRoL2JlZ2luEskBCgxBdXRoZW50aWNhdGUSJS5zYWFzLmFjY291bnRzLnYxLkF1dGhlbnRpY2F0ZVJlcXVlc3QaJi5zYWFzLmFjY291bnRzLnYxLkF1dGhlbnRpY2F0ZVJlc3BvbnNlImrC8xhGCAEQATABOjQKD3NhYXMuYXV0aC5sb2dpbgofc2Fhcy5hdXRoLm1mYV9jaGFsbGVuZ2Vfc3RhcnRlZBACQAFIAlAEWARgAYLT5JMCGjoBKiIVL3YxL2F1dGgvYXV0aGVudGljYXRlEoECChRDb21wbGV0ZU1GQUNoYWxsZW5nZRItLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXF1ZXN0Gi4uc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZU1GQUNoYWxsZW5nZVJlc3BvbnNlIokBwvMYZQgBEAEwATpRCg9zYWFzLmF1dGgubG9naW4KIXNhYXMuYXV0aC5tZmFfY2hhbGxlbmdlX2NvbXBsZXRlZAoZc2Fhcy5tZmEuYmFja3VwX2NvZGVfdXNlZBACQAFIAlAEWARgAWgBgtPkkwIaOgEqIhUvdjEvYXV0aC9tZmEvY29tcGxldGUSxAEKGUJlZ2luV2ViQXV0aG5NRkFDaGFsbGVuZ2USMi5zYWFzLmFjY291bnRzLnYxLkJlZ2luV2ViQXV0aG5NRkFDaGFsbGVuZ2VSZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5CZWdpbldlYkF1dGhuTUZBQ2hhbGxlbmdlUmVzcG9uc2UiPsLzGBQIARABMAE6AhABQAFIAlADWARgAYLT5JMCIDoBKiIbL3YxL2F1dGgvbWZhL3dlYmF1dGhuL2JlZ2luEpcCChxDb21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlEjUuc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZVdlYkF1dGhuTUZBQ2hhbGxlbmdlUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVNRkFDaGFsbGVuZ2VSZXNwb25zZSKPAcLzGGIIARABMAE6TgoPc2Fhcy5hdXRoLmxvZ2luCiFzYWFzLmF1dGgubWZhX2NoYWxsZW5nZV9jb21wbGV0ZWQKFnNhYXMubWZhLndlYmF1dGhuX3VzZWQQAkABSAJQBFgEYAFoAYLT5JMCIzoBKiIeL3YxL2F1dGgvbWZhL3dlYmF1dGhuL2NvbXBsZXRlEpIBCgxSZWZyZXNoVG9rZW4SJS5zYWFzLmFjY291bnRzLnYxLlJlZnJlc2hUb2tlblJlcXVlc3QaJi5zYWFzLmFjY291bnRzLnYxLlJlZnJlc2hUb2tlblJlc3BvbnNlIjPC8xgUCAEQATABOgIQAUABSAJQBFgEYAGC0+STAhU6ASoiEC92MS9hdXRoL3JlZnJlc2gS0QEKElN3aXRjaE9yZ2FuaXphdGlvbhIrLnNhYXMuYWNjb3VudHMudjEuU3dpdGNoT3JnYW5pemF0aW9uUmVxdWVzdBosLnNhYXMuYWNjb3VudHMudjEuU3dpdGNoT3JnYW5pemF0aW9uUmVzcG9uc2UiYMLzGDUIAhABMAE6Iwofc2Fhcy5hdXRoLm9yZ2FuaXphdGlvbl9zd2l0Y2hlZBACQAFIBVADWARgAYLT5JMCIToBKiIcL3YxL2F1dGgvc3dpdGNoLW9yZ2FuaXphdGlvbhJ1CgZMb2dvdXQSHy5zYWFzLmFjY291bnRzLnYxLkxvZ291dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMsLzGBQIARABMAE6AhABQAFIAlAEWANgAYLT5JMCFDoBKiIPL3YxL2F1dGgvbG9nb3V0Eq4BChJHZXRQcm92aWRlclNpZ25PdXQSKy5zYWFzLmFjY291bnRzLnYxLkdldFByb3ZpZGVyU2lnbk91dFJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLkdldFByb3ZpZGVyU2lnbk91dFJlc3BvbnNlIj3C8xgUCAEQATABOgIQAUABSAJQBFgDYAGC0+STAh86ASoiGi92MS9hdXRoL3Byb3ZpZGVyLXNpZ24tb3V0EoEBCgdHZXRKV0tTEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5Gh4uc2Fhcy5hY2NvdW50cy52MS5KV0tTUmVzcG9uc2UiPsLzGBQIARABMAE6AhABQAFIAVABWAFgAYLT5JMCIBIeL3YxL2F1dGgvLndlbGwta25vd24vandrcy5qc29uEsgBChtWYWxpZGF0ZUNsaWVudEF1dGhvcml6YXRpb24SNC5zYWFzLmFjY291bnRzLnYxLlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlcXVlc3QaNS5zYWFzLmFjY291bnRzLnYxLlZhbGlkYXRlQ2xpZW50QXV0aG9yaXphdGlvblJlc3BvbnNlIjzC8xgUCAEQATABOgIQAUABSAJQA1gDYAGC0+STAh46ASoiGS92MS9hdXRoL2NsaWVudHMvdmFsaWRhdGUS6QEKHElzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGUSNS5zYWFzLmFjY291bnRzLnYxLklzc3VlQ2xpZW50QXV0aG9yaXphdGlvbkNvZGVSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZUNsaWVudEF1dGhvcml6YXRpb25Db2RlUmVzcG9uc2UiWsLzGDEIAhABMAE6Hwobc2Fhcy5hdXRoLmNsaWVudF9hdXRob3JpemVkEAJAAUgFUANYBGABgtPkkwIfOgEqIhovdjEvYXV0aC9jbGllbnRzL2F1dGhvcml6ZRK2AQoTRXhjaGFuZ2VDbGllbnRUb2tlbhIsLnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VDbGllbnRUb2tlblJlcXVlc3QaLS5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlQ2xpZW50VG9rZW5SZXNwb25zZSJCwvMYJQgBEAEwAToTCg9zYWFzLmF1dGgubG9naW4QAkABSAJQBFgEYAGC0+STAhM6ASoiDi92MS9hdXRoL3Rva2VuQrsBChRjb20uc2Fhcy5hY2NvdW50cy52MUITQXV0aGVudGljYXRpb25Qcm90b1ABWixhY2NvdW50cy9wa2cvZ2VuL3NhYXMvYWNjb3VudHMvdjE7YWNjb3VudHN2MaICA1NBWKoCEFNhYXMuQWNjb3VudHMuVjHKAhBTYWFzXEFjY291bnRzXFYx4gIcU2Fhc1xBY2NvdW50c1xWMVxHUEJNZXRhZGF0YeoCElNhYXM6OkFjY291bnRzOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_saas_accounts_v1_common, file_saas_policy_v1_options]); /** * @generated from message saas.accounts.v1.OAuthCodeAuthentication @@ -449,7 +449,27 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * @generated from field: string refresh_token = 1; */ refreshToken: string; +}; + +/** + * Describes the message saas.accounts.v1.LogoutRequest. + * Use `create(LogoutRequestSchema)` to create a new message. + */ +export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 14); +/** + * GetProviderSignOutRequest asks for the URL that ends the session at the + * identity provider. It is a separate call rather than a field on the Logout + * response because `saas.accounts.v1` is additive: an RPC's response type is + * never changed (module/CONTRACT_VERSIONING.md), and every client generated + * against `Logout` returning Empty would have to be rebuilt. Asking separately + * also sequences better — a client can hold the URL before it signs out, so it + * has somewhere to send the person the moment the host session ends. + * + * @generated from message saas.accounts.v1.GetProviderSignOutRequest + */ +export type GetProviderSignOutRequest = Message<"saas.accounts.v1.GetProviderSignOutRequest"> & { /** * id_token_hint is the provider's id_token for the session being ended, as * the client still holds it. It is optional and client-supplied on purpose: @@ -460,31 +480,31 @@ export type LogoutRequest = Message<"saas.accounts.v1.LogoutRequest"> & { * where no hint is available. The provider may then ask the person to confirm * which session they are ending. * - * @generated from field: string id_token_hint = 2; + * @generated from field: string id_token_hint = 1; */ idTokenHint: string; }; /** - * Describes the message saas.accounts.v1.LogoutRequest. - * Use `create(LogoutRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.GetProviderSignOutRequest. + * Use `create(GetProviderSignOutRequestSchema)` to create a new message. */ -export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 14); +export const GetProviderSignOutRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 15); /** - * LogoutResponse carries what the host cannot do on the person's behalf. The - * host ends its own session unconditionally; reaching the identity provider + * GetProviderSignOutResponse carries what the host cannot do on the person's + * behalf. The host ends its own session itself; reaching the identity provider * requires the person's own user agent, because the provider's session cookie * is in their browser and not in any request accounts can make. * - * @generated from message saas.accounts.v1.LogoutResponse + * @generated from message saas.accounts.v1.GetProviderSignOutResponse */ -export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { +export type GetProviderSignOutResponse = Message<"saas.accounts.v1.GetProviderSignOutResponse"> & { /** * end_session_url is the provider's `end_session_endpoint` with the * RP-initiated logout parameters already applied, for the client to send the - * person's browser to after the host session has ended. Empty when this + * person's browser to once the host session has ended. Empty when this * deployment's provider publishes no `end_session_endpoint`, or when the * configured provider has no OIDC sign-out at all (the development fixture): * an empty value is the whole answer, and a client must treat it as "nothing @@ -496,11 +516,11 @@ export type LogoutResponse = Message<"saas.accounts.v1.LogoutResponse"> & { }; /** - * Describes the message saas.accounts.v1.LogoutResponse. - * Use `create(LogoutResponseSchema)` to create a new message. + * Describes the message saas.accounts.v1.GetProviderSignOutResponse. + * Use `create(GetProviderSignOutResponseSchema)` to create a new message. */ -export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 15); +export const GetProviderSignOutResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_authentication, 16); /** * @generated from message saas.accounts.v1.JWKSResponse @@ -517,7 +537,7 @@ export type JWKSResponse = Message<"saas.accounts.v1.JWKSResponse"> & { * Use `create(JWKSResponseSchema)` to create a new message. */ export const JWKSResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 16); + messageDesc(file_saas_accounts_v1_authentication, 17); /** * BeginOAuthRequest is the FE's first hop on the authorization-code @@ -545,7 +565,7 @@ export type BeginOAuthRequest = Message<"saas.accounts.v1.BeginOAuthRequest"> & * Use `create(BeginOAuthRequestSchema)` to create a new message. */ export const BeginOAuthRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 17); + messageDesc(file_saas_accounts_v1_authentication, 18); /** * @generated from message saas.accounts.v1.BeginOAuthResponse @@ -565,7 +585,7 @@ export type BeginOAuthResponse = Message<"saas.accounts.v1.BeginOAuthResponse"> * Use `create(BeginOAuthResponseSchema)` to create a new message. */ export const BeginOAuthResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 18); + messageDesc(file_saas_accounts_v1_authentication, 19); /** * ClientAuthorizationRequest is the authorization request a client encodes into @@ -606,7 +626,7 @@ export type ClientAuthorizationRequest = Message<"saas.accounts.v1.ClientAuthori * Use `create(ClientAuthorizationRequestSchema)` to create a new message. */ export const ClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 19); + messageDesc(file_saas_accounts_v1_authentication, 20); /** * @generated from message saas.accounts.v1.ValidateClientAuthorizationRequest @@ -623,7 +643,7 @@ export type ValidateClientAuthorizationRequest = Message<"saas.accounts.v1.Valid * Use `create(ValidateClientAuthorizationRequestSchema)` to create a new message. */ export const ValidateClientAuthorizationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 20); + messageDesc(file_saas_accounts_v1_authentication, 21); /** * ValidateClientAuthorizationResponse carries only what the login page needs to @@ -644,7 +664,7 @@ export type ValidateClientAuthorizationResponse = Message<"saas.accounts.v1.Vali * Use `create(ValidateClientAuthorizationResponseSchema)` to create a new message. */ export const ValidateClientAuthorizationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 21); + messageDesc(file_saas_accounts_v1_authentication, 22); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeRequest @@ -661,7 +681,7 @@ export type IssueClientAuthorizationCodeRequest = Message<"saas.accounts.v1.Issu * Use `create(IssueClientAuthorizationCodeRequestSchema)` to create a new message. */ export const IssueClientAuthorizationCodeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 22); + messageDesc(file_saas_accounts_v1_authentication, 23); /** * @generated from message saas.accounts.v1.IssueClientAuthorizationCodeResponse @@ -686,7 +706,7 @@ export type IssueClientAuthorizationCodeResponse = Message<"saas.accounts.v1.Iss * Use `create(IssueClientAuthorizationCodeResponseSchema)` to create a new message. */ export const IssueClientAuthorizationCodeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 23); + messageDesc(file_saas_accounts_v1_authentication, 24); /** * AuthorizationCodeGrant redeems a code issued by IssueClientAuthorizationCode. @@ -717,7 +737,7 @@ export type AuthorizationCodeGrant = Message<"saas.accounts.v1.AuthorizationCode * Use `create(AuthorizationCodeGrantSchema)` to create a new message. */ export const AuthorizationCodeGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 24); + messageDesc(file_saas_accounts_v1_authentication, 25); /** * ClientRefreshTokenGrant rotates a client's own refresh token. The presented @@ -738,7 +758,7 @@ export type ClientRefreshTokenGrant = Message<"saas.accounts.v1.ClientRefreshTok * Use `create(ClientRefreshTokenGrantSchema)` to create a new message. */ export const ClientRefreshTokenGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 25); + messageDesc(file_saas_accounts_v1_authentication, 26); /** * @generated from message saas.accounts.v1.ExchangeClientTokenRequest @@ -772,7 +792,7 @@ export type ExchangeClientTokenRequest = Message<"saas.accounts.v1.ExchangeClien * Use `create(ExchangeClientTokenRequestSchema)` to create a new message. */ export const ExchangeClientTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 26); + messageDesc(file_saas_accounts_v1_authentication, 27); /** * ExchangeClientTokenResponse returns both halves in the body. A public client @@ -803,7 +823,7 @@ export type ExchangeClientTokenResponse = Message<"saas.accounts.v1.ExchangeClie * Use `create(ExchangeClientTokenResponseSchema)` to create a new message. */ export const ExchangeClientTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_authentication, 27); + messageDesc(file_saas_accounts_v1_authentication, 28); /** * AuthService — JWT token issuance and session management @@ -883,16 +903,30 @@ export const AuthService: GenService<{ output: typeof SwitchOrganizationResponseSchema; }, /** - * Logout ends the host session and, when the identity provider publishes an - * `end_session_endpoint`, returns the RP-initiated logout URL for the client - * to complete. See module/AUTHENTICATION.md "Signing out". - * * @generated from rpc saas.accounts.v1.AuthService.Logout */ logout: { methodKind: "unary"; input: typeof LogoutRequestSchema; - output: typeof LogoutResponseSchema; + output: typeof EmptySchema; + }, + /** + * GetProviderSignOut returns the identity provider's RP-initiated logout URL, + * so a client can complete the half of a sign-out the host cannot: ending the + * provider's own session, which needs the person's browser. Public, like + * GetJWKS, because everything it answers with is already public — the + * deployment's client id and its registered post-logout redirect — and + * because a client needs it on a sign-out that has already ended the session + * whose bearer it would otherwise present. Empty when this provider offers no + * RP-initiated sign-out. See FEATURES.md "Signing out of the identity + * provider". + * + * @generated from rpc saas.accounts.v1.AuthService.GetProviderSignOut + */ + getProviderSignOut: { + methodKind: "unary"; + input: typeof GetProviderSignOutRequestSchema; + output: typeof GetProviderSignOutResponseSchema; }, /** * @generated from rpc saas.accounts.v1.AuthService.GetJWKS From 41bd5dc5e87527ec6fb1dcc90a21511fbfc8b15c Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:36:53 -0500 Subject: [PATCH 12/12] fix(accounts): regenerate the event catalog, move the pinned catalog counts, name the exemption's guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two checks failing at 9ac18dcaa, both from the new work and neither the managed-database qualification #1294 also hits: - `Immutable module package` — its first step (buf breaking) is already fixed; what failed next was `module-package run-generators` drift: the new `saas.audit.archive_exported` event belongs in the generated event catalog (`deployment/generated/asyncapi.json`, `event-catalog.json`, `communication.md`, `pkg/eventcatalog/catalog_gen.go`). Regenerated by the generator, not by hand. Running it locally needs the public `@bufbuild/protoc-gen-es` symlinked into the frontend prefix the template names — without it buf kills the sibling plugins and the log reads as an OOM; the shim is removed again before hashing the tree, which `base-integrity` checks. - `Codefly quality (test)` — `TestAuthorizationCatalogCompilationAndPolicyProjection`, one of three pinned catalog counts the new public RPC moves: fail-closed 28 → 29, gateway public 63 → 66 (REST plus both Connect spellings), REST public 18 → 19. Each delta is narrated beside the figure, as every other delta in those files is, and the REST case now also asserts the route by procedure and exposure so the number is anchored to a claim. The metric's exported name is now stated where a rule is written against it. Accounts exports over OTLP, so an alerting rule targets the collector's normalised spelling — `saas_accounts_rpc_policy_denied_total`, not the dotted instrument name — with `method` and `reason` as labels. MEASUREMENT_RUNBOOKS.md gives both spellings, cites this repository's own precedent (`saas.jobs.completed` → `saas_jobs_completed_total`, and the gauge `saas.retention.failing` keeping no suffix), and says plainly that a rule on the dotted form matches nothing and fires never — which looks exactly like "no denials". And the central floor's one exemption now names its backstop. `Issue` declares an org tenant and is outside the floor, so what refuses a cross-organisation call is the adapter: proven against a live store by the shared suite's "an installation issue cannot name a different organization" and by this repository's "a module workload is issued only where it is installed, within its pin, until uninstalled", both of which went red under enforcement and green again once the exemption was declared. `TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods` pins the exemption's extent, so a third method cannot quietly join it and leave a declared tenant enforced by nothing. Co-Authored-By: Claude Opus 5 --- AUTHZ.md | 19 +++- module/MEASUREMENT_RUNBOOKS.md | 21 ++++ module/deployment/generated/asyncapi.json | 25 +++++ module/deployment/generated/communication.md | 8 ++ .../deployment/generated/event-catalog.json | 100 ++++++++++++++++++ .../pkg/business/rpc_policy_coverage_test.go | 53 ++++++++++ .../code/pkg/cataloggen/authz_methods_test.go | 2 +- .../pkg/cataloggen/gateway_routes_test.go | 2 +- .../code/pkg/cataloggen/rest_surface_test.go | 9 +- .../code/pkg/eventcatalog/catalog_gen.go | 1 + 10 files changed, 234 insertions(+), 6 deletions(-) diff --git a/AUTHZ.md b/AUTHZ.md index 3c281218c..a218f595b 100644 --- a/AUTHZ.md +++ b/AUTHZ.md @@ -185,6 +185,16 @@ shape-authenticated method (`business.ShapeAuthenticated`, whose caller's credential is *in* the request body, so admission has no verified organization to resolve against). Everything else stays with the handler gates above. +The shape-authenticated exemption is the one that needs its backstop named, +because `Issue` declares an org tenant and is nonetheless outside the floor. A +cross-organisation call is refused by the adapter instead, and that is proven +against a live store in two harness cases: the shared suite's **"an installation +issue cannot name a different organization"**, and this repository's own **"a +module workload is issued only where it is installed, within its pin, until +uninstalled"**. The exemption's *extent* — exactly `Issue` and `GetDelegation`, +never a third method — is pinned by +`TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods`. + **It enforces by default.** `RBAC_CENTRAL_ENFORCEMENT` unset means enforce; `shadow` is an explicit opt-out that classifies only, and the boot warns naming it on every start; any other value refuses to boot. The coverage classification @@ -192,9 +202,12 @@ that drove the rollout (`saas.accounts.rpc_policy.coverage`) keeps running, sinc what it measures is the part this floor still cannot reach. Refusals are counted on `saas.accounts.rpc_policy.denied`, labelled by `method` -and by the gate that refused (`reason`) — see -[module/MEASUREMENT_RUNBOOKS.md](module/MEASUREMENT_RUNBOOKS.md) under -`authorization-denials`. +and by the gate that refused (`reason`). An alerting rule targets the +collector's normalised spelling of it, +**`saas_accounts_rpc_policy_denied_total`**, not the dotted instrument name — +see [module/MEASUREMENT_RUNBOOKS.md](module/MEASUREMENT_RUNBOOKS.md) under +`authorization-denials`, which gives both spellings and why the distinction +matters. ## Layer 2 — Permissions (RBAC, business layer) diff --git a/module/MEASUREMENT_RUNBOOKS.md b/module/MEASUREMENT_RUNBOOKS.md index d2d20f204..e90426b03 100644 --- a/module/MEASUREMENT_RUNBOOKS.md +++ b/module/MEASUREMENT_RUNBOOKS.md @@ -84,6 +84,27 @@ without reading the service's source. | `saas.accounts.rpc_policy.denied` | calls | `method`, `reason` | every authorization refusal, on both the gRPC and the Connect transport | | `saas.accounts.rpc_policy.coverage` | calls | `method`, `coverage` | every **admitted** call, classified by how much of its declared floor central admission can enforce (`ok`, `gap`, `unsupported`) | +**The name an alerting rule must target is not the name above.** Accounts +exports over OTLP (`otlpmetricgrpc`), and a rule is written against the +collector's Prometheus-normalised spelling: dots become underscores, and a +monotonic counter gains `_total`. So, exactly: + +| OTel instrument | What a promql rule targets | +| --- | --- | +| `saas.accounts.rpc_policy.denied` | **`saas_accounts_rpc_policy_denied_total`** | +| `saas.accounts.rpc_policy.coverage` | **`saas_accounts_rpc_policy_coverage_total`** | + +The attributes keep their names as labels: `method` and `reason` on the first, +`method` and `coverage` on the second. This is the same transformation every +other metric in this repository's own alert pack already relies on — compare +`saas.jobs.completed` in this file with `saas_jobs_completed_total` in +`services/accounts/code/pkg/metrics/slo_pack.json`, and `saas.retention.failing` +with `saas_retention_failing`, which keeps no suffix because it is a gauge. + +A rule targeting the dotted form, or the undotted form without `_total`, matches +nothing and fires never — which looks exactly like "no denials" and is the one +failure mode an authorization alert cannot afford. + `reason` is a closed set, never an error message — a message carries the organization and the permission, which would make the label unbounded and put tenant identifiers in a metrics backend: diff --git a/module/deployment/generated/asyncapi.json b/module/deployment/generated/asyncapi.json index 313fc2393..a6b0c0ea1 100644 --- a/module/deployment/generated/asyncapi.json +++ b/module/deployment/generated/asyncapi.json @@ -152,6 +152,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.audit.archive_exported": { + "address": "saas.audit.archive_exported", + "title": "saas.audit.archive_exported", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.audit.archive_exported" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.auth.client_authorized": { "address": "saas.auth.client_authorized", "title": "saas.auth.client_authorized", @@ -2345,6 +2356,13 @@ }, "title": "saas publishes saas.approval.timeout" }, + "send:saas.audit.archive_exported": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.audit.archive_exported" + }, + "title": "saas publishes saas.audit.archive_exported" + }, "send:saas.auth.client_authorized": { "action": "send", "channel": { @@ -3769,6 +3787,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.audit.archive_exported": { + "name": "saas.audit.archive_exported", + "title": "saas.audit.archive_exported", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.auth.client_authorized": { "name": "saas.auth.client_authorized", "title": "saas.auth.client_authorized", diff --git a/module/deployment/generated/communication.md b/module/deployment/generated/communication.md index 900100317..66c77a308 100644 --- a/module/deployment/generated/communication.md +++ b/module/deployment/generated/communication.md @@ -112,6 +112,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.audit.archive_exported + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.auth.client_authorized - **Publisher:** saas diff --git a/module/deployment/generated/event-catalog.json b/module/deployment/generated/event-catalog.json index a893d768b..6d5f44605 100644 --- a/module/deployment/generated/event-catalog.json +++ b/module/deployment/generated/event-catalog.json @@ -1304,6 +1304,106 @@ } ] }, + { + "type": "saas.audit.archive_exported", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.auth.client_authorized", "namespace": "saas", diff --git a/module/services/accounts/code/pkg/business/rpc_policy_coverage_test.go b/module/services/accounts/code/pkg/business/rpc_policy_coverage_test.go index 671c217c9..94389aa17 100644 --- a/module/services/accounts/code/pkg/business/rpc_policy_coverage_test.go +++ b/module/services/accounts/code/pkg/business/rpc_policy_coverage_test.go @@ -1,6 +1,7 @@ package business import ( + "slices" "testing" policyv1 "accounts/pkg/gen/saas/policy/v1" @@ -246,3 +247,55 @@ func TestSelfServiceUserWritesDeclareNoOrgPermission(t *testing.T) { require.Emptyf(t, policy.MethodPolicy.GetPermissions(), "%s must not declare an org permission for a tenant USER method", method) } } + +// The central floor's one exemption, held to its exact extent. +// +// `Issue` declares TENANT_REQUIREMENT_ORG_MEMBER and is nonetheless outside the +// floor, because its caller's credential is in the request body and admission +// never reads the body. That is sound only because something else refuses a +// cross-organisation call, and because the exemption covers exactly the methods +// whose authentication really is in their shape — a third method quietly +// joining the set would leave a declared org tenant enforced by nothing. +// +// Where the cross-organisation refusal is actually proven, both against a live +// store through the booted harness: +// +// - the shared suite's "an installation issue cannot name a different +// organization": an installation credential for the installed organisation, +// naming a foreign one, is PermissionDenied with reason "organization"; +// - this repository's own "a module workload is issued only where it is +// installed, within its pin, until uninstalled" +// (adapters/host_interface_conformance_test.go): `issue(globexOrganization)` +// is refused while the module is installed only at Acme, and refused again +// by the module envelope's pin after Globex installs it. +// +// Both were among the cases that went red when enforcement was turned on and +// green again once this exemption was declared, so they exercise the exempted +// path rather than some neighbouring one. +func TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods(t *testing.T) { + exempted := []string{HostIssueMethod, HostGetDelegationMethod} + for _, method := range exempted { + require.Truef(t, ShapeAuthenticated(method), "%s is shape-authenticated", method) + } + + // Nothing else is. The set is two constants rather than a lookup, so this + // is the only place a third entry would be noticed. + for _, policy := range RPCPolicies() { + if slices.Contains(exempted, policy.FullMethod) { + continue + } + require.Falsef(t, ShapeAuthenticated(policy.FullMethod), + "%s became shape-authenticated; the central tenant floor now exempts it, so its declared tenant is enforced only by its handler — say so here deliberately", + policy.FullMethod) + } + + // Issue declares an org tenant and the floor still declines it; that is the + // exemption, and it is what the conformance cases above then backstop. + issue, ok := LookupRPCPolicy(HostIssueMethod) + require.True(t, ok, "the host interface's Issue is classified") + require.Equal(t, policyv1.TenantRequirement_TENANT_REQUIREMENT_ORG_MEMBER, issue.MethodPolicy.GetTenant()) + _, enforced := CentralTenantEnforcement(issue) + require.False(t, enforced, "a shape-authenticated method is outside the central floor") + require.Equal(t, CoverageGap, ClassifyCentralCoverage(issue), + "and the coverage classifier must agree, or the two halves disagree per method") +} diff --git a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go index f2e898586..3d99c9fd1 100644 --- a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go +++ b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go @@ -52,7 +52,7 @@ func TestAuthorizationCatalogCompilationAndPolicyProjection(t *testing.T) { // number would be silently wrong, so this is read off the regenerated // generated/authz-methods.json. require.Equal(t, 55, internalCount) // ApplyManifest now authenticates its signed carrier on the tenant listener - require.Equal(t, 28, failClosedCount) // +3: the artifact interface's Approve, Authorize and Revoke, authenticated by the module's workload credential; +1: signed composition delivery; +2: the session interface's SignIn and ListFixturePeople are rate-limited as authentication and fail closed; +4: the authority interface's workload-credential calls (RegisterAdmissionKey, AttachResource, DetachResource, AuthorizeTransfer); +1: the installation interface's Current, authenticated by the module's workload credential + require.Equal(t, 29, failClosedCount) // +1: AuthService/GetProviderSignOut, the provider's RP-initiated logout URL, rate-limited as authentication like every other pre-credential route and so fail-closed; +3: the artifact interface's Approve, Authorize and Revoke, authenticated by the module's workload credential; +1: signed composition delivery; +2: the session interface's SignIn and ListFixturePeople are rate-limited as authentication and fail closed; +4: the authority interface's workload-credential calls (RegisterAdmissionKey, AttachResource, DetachResource, AuthorizeTransfer); +1: the installation interface's Current, authenticated by the module's workload credential require.Equal(t, 2, factorAttemptCount) require.Equal( t, diff --git a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go index ff06cf1e5..9d6b3dc20 100644 --- a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go +++ b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go @@ -33,7 +33,7 @@ func TestGatewayRouteCatalogCompilationAndParity(t *testing.T) { } require.NotEqual(t, policyv1.Exposure_EXPOSURE_INTERNAL, route.GetExposure()) } - require.Equal(t, 63, publicCount) // +3: the artifact interface's Approve, Authorize and Revoke (authenticated by the module's workload credential and its parent); +5: the authority interface's Keys, Check and ListResources (authenticated by the token they check) and the session interface's SignIn and ListFixturePeople (before anyone signs in); +1: the installation interface's Current (authenticated by the module's workload credential and its parent); ApplyManifest is public on accounts' own listener but never routed (ServedInClusterOnly) + require.Equal(t, 66, publicCount) // +3: AuthService/GetProviderSignOut on all three spellings it is routed under (REST /v1/auth/provider-sign-out and both Connect procedures) — public because the client id and registered post-logout redirect it echoes are already public, and a caller asking after a sign-out has no session left to present; +3: the artifact interface's Approve, Authorize and Revoke (authenticated by the module's workload credential and its parent); +5: the authority interface's Keys, Check and ListResources (authenticated by the token they check) and the session interface's SignIn and ListFixturePeople (before anyone signs in); +1: the installation interface's Current (authenticated by the module's workload credential and its parent); ApplyManifest is public on accounts' own listener but never routed (ServedInClusterOnly) require.Nil(t, byMatch["POST /saas.accounts.v1.APIKeyService/ValidateAPIKey"]) require.Equal(t, policyv1.Exposure_EXPOSURE_PUBLIC, byMatch["POST /saas.accounts.v1.AuthService/BeginOAuth"].GetExposure()) diff --git a/module/services/accounts/code/pkg/cataloggen/rest_surface_test.go b/module/services/accounts/code/pkg/cataloggen/rest_surface_test.go index ed1c5fb4e..a9c61c833 100644 --- a/module/services/accounts/code/pkg/cataloggen/rest_surface_test.go +++ b/module/services/accounts/code/pkg/cataloggen/rest_surface_test.go @@ -32,7 +32,14 @@ func TestRESTSurfaceCompilationAndExposure(t *testing.T) { publicCount++ } } - require.Equal(t, 18, publicCount) + // +1 for AuthService/GetProviderSignOut (POST /v1/auth/provider-sign-out): + // the RP-initiated logout URL, public for the same reason GetJWKS is. + require.Equal(t, 19, publicCount) + require.Equal(t, + "/saas.accounts.v1.AuthService/GetProviderSignOut", + routes["POST /v1/auth/provider-sign-out"].GetProcedure()) + require.Equal(t, policyv1.Exposure_EXPOSURE_PUBLIC, + routes["POST /v1/auth/provider-sign-out"].GetExposure()) require.Nil(t, routes["POST /v1/permissions:check"]) require.Nil(t, routes["POST /v1/api-keys:validate"]) require.Equal(t, "/saas.accounts.v1.UserService/RegisterUser", routes["POST /v1/users"].GetProcedure()) diff --git a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go index df6c60ae0..646c35789 100644 --- a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go +++ b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go @@ -38,6 +38,7 @@ var published = [...]PublishedEvent{ {Type: "saas.approval.denied", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.approval.escalated", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.approval.timeout", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.audit.archive_exported", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.auth.client_authorized", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.auth.login", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.auth.magic_link_login", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"},