mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 22:44:25 +00:00
# Which Problems Are Solved
In the "new" structured logging, request details were added in different
middlewares, such as request, instance and user IDs. This meant the the
upstream request logging middleware did not have access to metadata that
got added later, resulting in incomplete logs. Furthermore it was not
possible to correlate an API error response to log output.
# How the Problems Are Solved
A mutable request details object is added to the context early on. When
the api authz function run, the instance and user IDs are added to this
object as they become available. Every logline emitted after this
(time-wise) will then all contain these details under the `request` log
group.
<details>
<summary>example output in JSON</summary>
```json
{
"time": "2026-03-13T19:21:20.890428806Z",
"level": "INFO",
"source": {
"function": "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware.LogHandler.func1.1",
"file": "/workspaces/zitadel/internal/api/grpc/server/connect_middleware/log_interceptor.go",
"line": 34
},
"msg": "request served",
"request": {
"id": "d6q67c04vtjmi77cbbpg",
"instance_host": "localhost:8080",
"instance_id": "362349751439458307",
"user_id": "362349751440048131"
},
"TraceID": "a9e0fee3522224f3583bbdcda737f4b4",
"SpanID": "a563056eee36920a",
"stream": "request",
"version": "2026-03-13T19:20:59Z",
"protocol": "connect",
"service": "zitadel.user.v2.UserService",
"http_method": "POST",
"path": "/zitadel.user.v2.UserService/ListUsers",
"code": "code_0",
"duration": 12254350
}
```
</details>
Request IDs are now also returned with a response header or metadata.
Depending on the protocol:
- HTTP calls always return the request ID as header, regardless of
status
- gRPC calls always return the request ID as header, even if there was
an error
- connect RPC calls returns the request ID as header on success, trailer
in case of error. This is because header must be set on the response
object, which is nil in case of error. When there is an error, metadata
can be added which are then sent as trailers.
# Additional Changes
- Use the existing call duration middleware for both request ID and
logging for a consistent request start timestamp in all layers.
- Upgrade sloggcp for some fixes (notably TraceID)
- Modify the NoCache middleware so it uses `SetHeaders` instead of
`SendHeaders`. The latter prevented any other handler from setting
headers, including the new request ID middleware.
# Additional Context
Follow up on demo of:
- https://github.com/zitadel/zitadel/pull/11159
- https://github.com/zitadel/zitadel/pull/11435
- backport to v4
---------
Co-authored-by: Copilot <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Marco A. <[email protected]>
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"github.com/zitadel/oidc/v3/pkg/op"
|
|
|
|
"github.com/zitadel/zitadel/backend/v3/instrumentation/metrics"
|
|
"github.com/zitadel/zitadel/internal/api/assets"
|
|
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
|
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
|
"github.com/zitadel/zitadel/internal/api/ui/login"
|
|
"github.com/zitadel/zitadel/internal/auth/repository"
|
|
"github.com/zitadel/zitadel/internal/cache"
|
|
"github.com/zitadel/zitadel/internal/command"
|
|
"github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/domain/federatedlogout"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/notification/handlers"
|
|
"github.com/zitadel/zitadel/internal/query"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
type Config struct {
|
|
CodeMethodS256 bool
|
|
AuthMethodPost bool
|
|
AuthMethodPrivateKeyJWT bool
|
|
GrantTypeRefreshToken bool
|
|
RequestObjectSupported bool
|
|
DefaultAccessTokenLifetime time.Duration
|
|
DefaultIdTokenLifetime time.Duration
|
|
DefaultRefreshTokenIdleExpiration time.Duration
|
|
DefaultRefreshTokenExpiration time.Duration
|
|
JWKSCacheControlMaxAge time.Duration
|
|
CustomEndpoints *EndpointConfig
|
|
DeviceAuth *DeviceAuthorizationConfig
|
|
DefaultLoginURLV2 string
|
|
DefaultLogoutURLV2 string
|
|
PublicKeyCacheMaxAge time.Duration
|
|
DefaultBackChannelLogoutLifetime time.Duration
|
|
BackChannelLogout handlers.BackChannelLogoutWorkerConfig
|
|
}
|
|
|
|
// BackChannelLogoutConfig returns the BackChannelLogoutWorkerConfig and takes the deprecated TokenLifetime into account.
|
|
func (c *Config) BackChannelLogoutConfig() *handlers.BackChannelLogoutWorkerConfig {
|
|
if c.DefaultBackChannelLogoutLifetime == 0 {
|
|
return &c.BackChannelLogout
|
|
}
|
|
c.BackChannelLogout.TokenLifetime = c.DefaultBackChannelLogoutLifetime
|
|
return &c.BackChannelLogout
|
|
}
|
|
|
|
type EndpointConfig struct {
|
|
Auth *Endpoint
|
|
Token *Endpoint
|
|
Introspection *Endpoint
|
|
Userinfo *Endpoint
|
|
Revocation *Endpoint
|
|
EndSession *Endpoint
|
|
Keys *Endpoint
|
|
DeviceAuth *Endpoint
|
|
}
|
|
|
|
type Endpoint struct {
|
|
Path string
|
|
URL string
|
|
}
|
|
|
|
type OPStorage struct {
|
|
repo repository.Repository
|
|
command *command.Commands
|
|
query *query.Queries
|
|
eventstore *eventstore.Eventstore
|
|
defaultLoginURL string
|
|
defaultLoginURLV2 string
|
|
defaultLogoutURLV2 string
|
|
defaultAccessTokenLifetime time.Duration
|
|
defaultIdTokenLifetime time.Duration
|
|
defaultRefreshTokenIdleExpiration time.Duration
|
|
defaultRefreshTokenExpiration time.Duration
|
|
encAlg crypto.EncryptionAlgorithm
|
|
assetAPIPrefix func(ctx context.Context) string
|
|
contextToIssuer func(context.Context) string
|
|
federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]
|
|
}
|
|
|
|
// Provider is used to overload certain [op.Provider] methods
|
|
type Provider struct {
|
|
*op.Provider
|
|
accessTokenKeySet oidc.KeySet
|
|
idTokenHintKeySet oidc.KeySet
|
|
}
|
|
|
|
// IDTokenHintVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context.
|
|
func (o *Provider) IDTokenHintVerifier(ctx context.Context) *op.IDTokenHintVerifier {
|
|
return op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), o.idTokenHintKeySet, op.WithSupportedIDTokenHintSigningAlgorithms(
|
|
supportedSigningAlgs()...,
|
|
))
|
|
}
|
|
|
|
// AccessTokenVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context.
|
|
func (o *Provider) AccessTokenVerifier(ctx context.Context) *op.AccessTokenVerifier {
|
|
return op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), o.accessTokenKeySet, op.WithSupportedAccessTokenSigningAlgorithms(
|
|
supportedSigningAlgs()...,
|
|
))
|
|
}
|
|
|
|
func NewServer(
|
|
ctx context.Context,
|
|
config Config,
|
|
defaultLogoutRedirectURI string,
|
|
externalSecure bool,
|
|
command *command.Commands,
|
|
query *query.Queries,
|
|
repo repository.Repository,
|
|
encryptionAlg crypto.EncryptionAlgorithm,
|
|
targetEncryptionAlgorithm crypto.EncryptionAlgorithm,
|
|
cryptoKey []byte,
|
|
es *eventstore.Eventstore,
|
|
userAgentCookie, instanceHandler func(http.Handler) http.Handler,
|
|
accessHandler *middleware.AccessInterceptor,
|
|
fallbackLogger *slog.Logger,
|
|
hashConfig crypto.HashConfig,
|
|
federatedLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
|
|
) (*Server, error) {
|
|
opConfig, err := createOPConfig(config, defaultLogoutRedirectURI, cryptoKey)
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w")
|
|
}
|
|
storage := newStorage(config, command, query, repo, encryptionAlg, es, ContextToIssuer, federatedLogoutCache)
|
|
keyCache := newPublicKeyCache(ctx, config.PublicKeyCacheMaxAge, queryKeyFunc(query))
|
|
accessTokenKeySet := newOidcKeySet(keyCache, withKeyExpiryCheck(true))
|
|
idTokenHintKeySet := newOidcKeySet(keyCache)
|
|
|
|
var options []op.Option
|
|
if !externalSecure {
|
|
options = append(options, op.WithAllowInsecure())
|
|
}
|
|
provider, err := op.NewProvider(
|
|
opConfig,
|
|
storage,
|
|
IssuerFromContext,
|
|
options...,
|
|
)
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "OIDC-DAtg3", "cannot create provider")
|
|
}
|
|
hasher, err := hashConfig.NewHasher()
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "OIDC-Aij4e", "cannot create secret hasher")
|
|
}
|
|
server := &Server{
|
|
LegacyServer: op.NewLegacyServer(&Provider{
|
|
Provider: provider,
|
|
accessTokenKeySet: accessTokenKeySet,
|
|
idTokenHintKeySet: idTokenHintKeySet,
|
|
}, endpoints(config.CustomEndpoints)),
|
|
repo: repo,
|
|
query: query,
|
|
command: command,
|
|
accessTokenKeySet: accessTokenKeySet,
|
|
idTokenHintKeySet: idTokenHintKeySet,
|
|
defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
|
|
defaultLoginURLV2: config.DefaultLoginURLV2,
|
|
defaultLogoutURLV2: config.DefaultLogoutURLV2,
|
|
defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime,
|
|
defaultIdTokenLifetime: config.DefaultIdTokenLifetime,
|
|
jwksCacheControlMaxAge: config.JWKSCacheControlMaxAge,
|
|
fallbackLogger: fallbackLogger,
|
|
hasher: hasher,
|
|
encAlg: encryptionAlg,
|
|
targetEncryptionAlgorithm: targetEncryptionAlgorithm,
|
|
opCrypto: op.NewAESCrypto(opConfig.CryptoKey),
|
|
assetAPIPrefix: assets.AssetAPI(),
|
|
}
|
|
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
|
|
server.Handler = op.RegisterLegacyServer(server,
|
|
server.authorizeCallbackHandler,
|
|
op.WithFallbackLogger(fallbackLogger),
|
|
op.WithHTTPMiddleware(
|
|
middleware.CallDurationHandler,
|
|
middleware.RequestDetailsHandler(),
|
|
middleware.MetricsHandler(metricTypes),
|
|
middleware.TraceHandler(),
|
|
middleware.LogHandler("oidc"),
|
|
middleware.RecoverHandler(writeRecoverError),
|
|
middleware.NoCacheInterceptor().Handler,
|
|
instanceHandler,
|
|
userAgentCookie,
|
|
http_utils.CopyHeadersToContext,
|
|
accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(config.CustomEndpoints)),
|
|
middleware.ActivityHandler,
|
|
))
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func ContextToIssuer(ctx context.Context) string {
|
|
return http_utils.DomainContext(ctx).Origin()
|
|
}
|
|
|
|
func IssuerFromContext(_ bool) (op.IssuerFromRequest, error) {
|
|
return func(r *http.Request) string {
|
|
return ContextToIssuer(r.Context())
|
|
}, nil
|
|
}
|
|
|
|
func publicAuthPathPrefixes(endpoints *EndpointConfig) []string {
|
|
authURL := op.DefaultEndpoints.Authorization.Relative()
|
|
keysURL := op.DefaultEndpoints.JwksURI.Relative()
|
|
if endpoints == nil {
|
|
return []string{oidc.DiscoveryEndpoint, authURL, keysURL}
|
|
}
|
|
if endpoints.Auth != nil && endpoints.Auth.Path != "" {
|
|
authURL = endpoints.Auth.Path
|
|
}
|
|
if endpoints.Keys != nil && endpoints.Keys.Path != "" {
|
|
keysURL = endpoints.Keys.Path
|
|
}
|
|
return []string{oidc.DiscoveryEndpoint, authURL, keysURL}
|
|
}
|
|
|
|
func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []byte) (*op.Config, error) {
|
|
opConfig := &op.Config{
|
|
DefaultLogoutRedirectURI: defaultLogoutRedirectURI,
|
|
CodeMethodS256: config.CodeMethodS256,
|
|
AuthMethodPost: config.AuthMethodPost,
|
|
AuthMethodPrivateKeyJWT: config.AuthMethodPrivateKeyJWT,
|
|
GrantTypeRefreshToken: config.GrantTypeRefreshToken,
|
|
RequestObjectSupported: config.RequestObjectSupported,
|
|
DeviceAuthorization: config.DeviceAuth.toOPConfig(),
|
|
}
|
|
if cryptoLength := len(cryptoKey); cryptoLength != 32 {
|
|
return nil, zerrors.ThrowInternalf(nil, "OIDC-D43gf", "crypto key must be 32 bytes, but is %d", cryptoLength)
|
|
}
|
|
copy(opConfig.CryptoKey[:], cryptoKey)
|
|
return opConfig, nil
|
|
}
|
|
|
|
func newStorage(
|
|
config Config,
|
|
command *command.Commands,
|
|
query *query.Queries,
|
|
repo repository.Repository,
|
|
encAlg crypto.EncryptionAlgorithm,
|
|
es *eventstore.Eventstore,
|
|
contextToIssuer func(context.Context) string,
|
|
federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout],
|
|
) *OPStorage {
|
|
return &OPStorage{
|
|
repo: repo,
|
|
command: command,
|
|
query: query,
|
|
eventstore: es,
|
|
defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
|
|
defaultLoginURLV2: config.DefaultLoginURLV2,
|
|
defaultLogoutURLV2: config.DefaultLogoutURLV2,
|
|
defaultAccessTokenLifetime: config.DefaultAccessTokenLifetime,
|
|
defaultIdTokenLifetime: config.DefaultIdTokenLifetime,
|
|
defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration,
|
|
defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration,
|
|
encAlg: encAlg,
|
|
assetAPIPrefix: assets.AssetAPI(),
|
|
contextToIssuer: contextToIssuer,
|
|
federateLogoutCache: federateLogoutCache,
|
|
}
|
|
}
|
|
|
|
func (o *OPStorage) Health(ctx context.Context) error {
|
|
return o.repo.Health(ctx)
|
|
}
|