-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathhooks.go
More file actions
295 lines (276 loc) · 14.8 KB
/
Copy pathhooks.go
File metadata and controls
295 lines (276 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
package zeroid
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/highflame-ai/zeroid/domain"
"github.com/highflame-ai/zeroid/internal/service"
)
// ClaimsEnricher is called during JWT issuance to add custom claims.
// The claims map already contains standard ZeroID claims; the enricher may add or override entries.
type ClaimsEnricher func(claims map[string]any, identity *domain.Identity, grantType domain.GrantType)
// GrantHandler implements a custom OAuth2 grant type.
// The handler receives the full token request and returns an access token.
// Returning an error causes a 400 response.
type GrantHandler func(ctx context.Context, req GrantRequest) (*domain.AccessToken, error)
// GrantRequest holds the parsed token endpoint fields passed to custom grant handlers.
type GrantRequest struct {
GrantType string
AccountID string
ProjectID string
ClientID string
Scope string
UserID string
UserEmail string
UserName string
ApplicationID string
AdditionalClaims map[string]any
// Role and PrivilegeScope are authorization claims
// minted into the `role` (string) and `privilege_scope` (array) JWT claims.
// They are honoured ONLY on the trusted-service external-principal exchange
// path (Server.ExternalPrincipalExchange, gated by TrustedServiceValidator)
// and can never be injected via AdditionalClaims (both names are reserved).
Role string
PrivilegeScope []string
// Audience is an OPTIONAL, server-recognized audience-profile name (e.g.
// "codeoid"). On the trusted external-principal exchange
// (Server.ExternalPrincipalExchange) a recognized value stamps the issued
// token's `aud` claim and adds that profile's fixed, server-defined scope
// set to the `scopes` claim. Callers never supply scopes through this
// field — the name maps to a hard-coded profile inside zeroid. An empty
// value leaves issuance unchanged; a non-empty value that names no known
// profile is rejected with `invalid_target` (RFC 8693) rather than
// silently downgraded to a default token.
Audience string
// IssueRefreshToken requests that the external-principal exchange ALSO mint a
// rotating refresh token (returned as `refresh_token` in the token response)
// so the external principal can keep its session alive by self-rotating at
// /oauth2/token — no broker re-mint on a timer. Honoured ONLY on
// Server.ExternalPrincipalExchange AND ONLY for a profiled Audience; ignored
// otherwise. The principal presents the Audience name as `client_id` when
// rotating; every rotation re-stamps the same `aud`/scope profile.
IssueRefreshToken bool
}
// Principal is the resolved caller at /oauth2/authorize — the tenant +
// user binding that gets baked into the issued authorization code JWT.
// Re-exported from internal/service so deployer code stays at the
// top-level zeroid public surface; both names refer to the same type.
//
// See internal/service/principal.go for the canonical doc comment.
type Principal = service.Principal
// AuthorizeRequest is the typed, read-only snapshot of the parsed
// /oauth2/authorize request handed to every PrincipalResolver. Resolvers
// see this — never net/http types — so the extensibility hook stays
// consistent with zeroid's other typed-struct boundaries.
//
// See internal/service/principal.go for the canonical doc comment.
type AuthorizeRequest = service.AuthorizeRequest
// PrincipalResolver authenticates the caller at /oauth2/authorize.
// Registered via Server.RegisterPrincipalResolver and tried in
// registration order; the first resolver to return a non-nil Principal
// wins. Return ErrPrincipalNotApplicable to defer to the next resolver;
// any other error fails the request with 401 invalid_client.
//
// See internal/service/principal.go for the canonical doc comment.
type PrincipalResolver = service.PrincipalResolver
// ErrPrincipalNotApplicable is the sentinel returned by a
// PrincipalResolver that does not apply to the current request. zeroid
// moves to the next registered resolver; when every resolver returns
// this sentinel, the request fails with 401 invalid_client.
var ErrPrincipalNotApplicable = service.ErrPrincipalNotApplicable
// ErrNoResolversRegistered is the sentinel surfaced by zeroid when
// /oauth2/authorize is reached but no PrincipalResolver has been
// registered via Server.RegisterPrincipalResolver. The handler maps
// this to 503 Service Unavailable so the deployer sees a clear
// "you forgot to wire this up" signal rather than an ambiguous 401.
//
// Deployers don't typically observe this sentinel directly — it's
// emitted by zeroid's chain walker and consumed by the handler. The
// re-export exists so deployer tests can match on it via errors.Is.
var ErrNoResolversRegistered = service.ErrNoResolversRegistered
// APIKeyResolution is the public projection returned by
// Server.ResolveAPIKey. Narrow + stable — does not leak zeroid
// internals (*domain.Identity, *domain.APIKey row, credential-policy
// records). Consumers map this onto whatever shape their layer needs
// (typically zeroid.Principal in a PrincipalResolver implementation).
//
// See internal/service/oauth.go (APIKeyResolution definition) for the
// canonical doc comment + field-level semantics.
type APIKeyResolution = service.APIKeyResolution
// AdminAuthMiddleware is an optional middleware applied to the admin API router.
// When set, every request to the admin port passes through this middleware before
// reaching any handler. Use this to add authentication (Bearer JWT, mTLS, API key,
// or any custom scheme) when embedding ZeroID as a library.
//
// When nil (the default), the admin API has no authentication — protect it at the
// network layer (VPN, service mesh, localhost-only binding, firewall rules).
type AdminAuthMiddleware func(next http.Handler) http.Handler
// OAuthClientConfig holds all fields for registering an OAuth2 client (RFC 7591).
// Used by EnsureClient for startup seeding and by deployers for programmatic registration.
type OAuthClientConfig struct {
ClientID string
Name string
Description string
Confidential bool
TokenEndpointAuthMethod string
GrantTypes []string
Scopes []string
RedirectURIs []string
AccessTokenTTL int
RefreshTokenTTL int
JWKSURI string
JWKS json.RawMessage
SoftwareID string
SoftwareVersion string
Contacts []string
Metadata json.RawMessage
// ClientNotificationEndpoint is the HTTPS callback CIBA ping mode posts to.
// Empty for clients that only use polling mode.
ClientNotificationEndpoint string
// BackchannelTokenDeliveryMode declares which CIBA delivery mode the client
// supports: "poll" (default), "ping", or "push". ping/push require a
// non-empty ClientNotificationEndpoint.
BackchannelTokenDeliveryMode string
}
// TrustedServiceValidator checks whether the current request comes from a trusted
// internal service that is allowed to perform external principal token exchange
// (RFC 8693). Implementations read from context (set by deployer-provided global
// middleware) and return the service name on success, or an error to reject.
//
// Set via Server.TrustedServiceValidator() after NewServer.
type TrustedServiceValidator func(ctx context.Context) (serviceName string, err error)
// BackchannelNotification is the payload handed to a BackchannelNotifier when
// a new CIBA authentication request is created. The notifier is responsible
// for delivering an approval prompt to the user out-of-band — push, email,
// SMS, voice, anything — and must not block the request-creation response
// (the service invokes the notifier in a goroutine).
//
// Fields mirror the OpenID CIBA spec's request shape so deployers can pass
// the payload directly to their notification provider without re-mapping.
type BackchannelNotification struct {
AuthReqID string
AccountID string
ProjectID string
ClientID string
LoginHint string
// GroupHint is the CIBA extension parameter for role/group-targeted
// approval. zeroid treats the value as opaque; deployers choose
// their own namespace convention (e.g. "highflame:role:finance_lead",
// "pd:schedule:P12345"). At least one of {LoginHint, GroupHint} is
// guaranteed non-empty when this notifier fires — the server-side
// validator enforces it at bc-authorize time. Empty when the client
// supplied LoginHint only (the canonical CIBA per-user case).
GroupHint string
Scope string
BindingMessage string
ExpiresAt time.Time
// AuthorizationDetails carries the RFC 9396 RAR payload parsed at
// bc-authorize time. Empty when the client did not supply
// authorization_details (legacy CIBA flow), or when the payload was
// rejected by a registered per-type validator (in which case the
// request was never created and this notifier is not invoked).
// Notifiers should render typed approval prompts from this field when
// non-empty; scope and binding_message remain the fallback for clients
// that have not adopted RAR.
AuthorizationDetails domain.AuthorizationDetails
}
// BackchannelNotifier delivers a CIBA approval prompt to the end user via an
// out-of-band channel selected by the deployer (push, email, SMS, etc.).
//
// ZeroID ships with no built-in notifier. Set one via Server.SetBackchannelNotifier.
// Returning an error records last_notify_error on the request row for
// debuggability but does not block request creation — the user may approve
// through another channel.
type BackchannelNotifier func(ctx context.Context, n BackchannelNotification) error
// AuthorizationDetailValidator is the deployer-supplied per-type validator
// for RFC 9396 RAR `authorization_details` entries. Registered against a
// specific `type` discriminator via Server.RegisterAuthorizationDetailValidator;
// invoked at bc-authorize time for every element whose `type` field matches.
//
// The validator receives the original JSON bytes of the element (preserving
// key order and any deployer-specific fields beyond `type`). It MUST return
// nil to accept or a descriptive error to reject — a rejection fails the
// entire bc-authorize request with OAuth error `invalid_authorization_details`
// (RFC 9396 §5.4).
//
// The registry is strictly per-`type`: unregistered `type` values pass
// outer-shape validation and are forwarded to the BackchannelNotifier
// with no extra checks. A type-allowlist that REJECTS unknown types is
// not expressible via this hook in the current release — there is no
// catch-all / fallback registration, and the BackchannelNotifier fires
// after the bc-authorize response is sent (an error there records
// `last_notify_error` on the row but does not surface as a 400 to the
// client). Deployers that need strict allow-listing today must front
// zeroid with a thin shim that screens `authorization_details` before
// forwarding. A future release may add a fallback validator hook.
//
// Validators run synchronously on the bc-authorize request path; keep them
// fast (no network I/O, no DB queries beyond in-process caches).
type AuthorizationDetailValidator func(raw json.RawMessage) error
// RevocationEvent is the payload handed to a RevocationNotifier after a
// token or credential has been revoked and the revocation has committed to
// the database. Exactly one event is emitted per revoked JTI — a cascade
// that revokes N credentials (e.g. an identity deactivation that walks the
// delegation tree) fires N events, one per affected credential.
//
// zeroid ships no built-in fan-out for these events: it does not own a
// Redis channel, a message bus, or any deny-set. The embedding application
// sets a RevocationNotifier via
// Server.SetRevocationNotifier and is responsible for whatever propagation
// it needs — publishing to its own Redis channel, writing to a shared
// deny-set, emitting a webhook, etc. This keeps zeroid Redis-agnostic by
// design.
//
// Fields:
// - JTI is the revoked credential's `jti` claim — the deny-set key the
// subscriber should block. For refresh-token reuse revocation (which
// concerns opaque, hashed refresh tokens that carry no JWT id), JTI
// carries the refresh-token row's UUID instead, so the value is still a
// stable, unique handle for the revoked artifact.
// - IdentityID is the owning identity's UUID. Empty when the revoked
// credential was not tied to a stored identity row (e.g. a synthetic
// external-principal carrier) or, for refresh tokens, when no identity
// was linked.
// - AccountID / ProjectID scope the revocation to a tenant. Subscribers
// MUST key their deny-set by (account_id, project_id, jti) to preserve
// multi-tenant isolation.
// - ExpiresAt is the revoked artifact's own expiry. Subscribers can size
// their deny-set entry's TTL to this instant: once the token would have
// expired anyway, the deny-set entry can be dropped because verification
// fails on `exp` regardless.
// - Reason mirrors the revoke reason recorded on the row
// (e.g. "oauth2_revocation", "identity_deactivated",
// "auto-revoked by CAE signal …", "refresh_token_reuse").
// - RevokedAt is the wall-clock instant the revocation was applied.
type RevocationEvent struct {
JTI string `json:"jti"`
IdentityID string `json:"identity_id"`
AccountID string `json:"account_id"`
ProjectID string `json:"project_id"`
ExpiresAt time.Time `json:"exp"`
Reason string `json:"reason"`
RevokedAt time.Time `json:"revoked_at"`
}
// RevocationNotifier observes every token/credential revocation so the
// embedding application can fan it out to its own infrastructure (a Redis
// deny-set channel, a webhook, an audit pipeline). zeroid ships with no
// built-in notifier; set one via Server.SetRevocationNotifier.
//
// The notifier fires AFTER the revocation has committed to the database, on
// a detached goroutine, so its latency never blocks the request that caused
// the revocation (RFC 7009 revoke, CAE signal ingest, refresh-token reuse
// detection, identity deactivation). It is invoked exactly once per revoked
// JTI: a cascade revoking N credentials fires N times.
//
// Returning an error is logged (zerolog, warn level) but is NOT propagated
// to the caller — a failed fan-out must never roll back or fail the
// revocation itself, which has already committed. The notifier MUST be
// safe for concurrent invocation and MUST NOT block indefinitely; it runs
// under a bounded-timeout context derived from the server's lifecycle
// context (cancelled on Server.Shutdown).
//
// When no notifier is set (the default), revocation behaviour is unchanged
// — there is no new required configuration and no behavioural difference for
// existing deployers.
type RevocationNotifier func(ctx context.Context, e RevocationEvent) error