mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 00:27:31 +00:00
perf(oidc): optimize the introspection endpoint (#6909)
* get key by id and cache them
* userinfo from events for v2 tokens
* improve keyset caching
* concurrent token and client checks
* client and project in single query
* logging and otel
* drop owner_removed column on apps and authN tables
* userinfo and project roles in go routines
* get oidc user info from projections and add actions
* add avatar URL
* some cleanup
* pull oidc work branch
* remove storage from server
* add config flag for experimental introspection
* legacy introspection flag
* drop owner_removed column on user projections
* drop owner_removed column on useer_metadata
* query userinfo unit test
* query introspection client test
* add user_grants to the userinfo query
* handle PAT scopes
* bring triggers back
* test instance keys query
* add userinfo unit tests
* unit test keys
* go mod tidy
* solve some bugs
* fix missing preferred login name
* do not run triggers in go routines, they seem to deadlock
* initialize the trigger handlers late with a sync.OnceValue
* Revert "do not run triggers in go routines, they seem to deadlock"
This reverts commit 2a03da2127
.
* add missing translations
* chore: update go version for linting
* pin oidc version
* parse a global time location for query test
* fix linter complains
* upgrade go lint
* fix more linting issues
---------
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
200
internal/api/oidc/introspect.go
Normal file
200
internal/api/oidc/introspect.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v3/pkg/op"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
errz "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionRequest]) (resp *op.Response, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
if s.features.LegacyIntrospection {
|
||||
return s.LegacyServer.Introspect(ctx, r)
|
||||
}
|
||||
if s.features.TriggerIntrospectionProjections {
|
||||
// Execute all triggers in one concurrent sweep.
|
||||
query.TriggerIntrospectionProjections(ctx)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
clientChan := make(chan *instrospectionClientResult)
|
||||
go s.instrospectionClientAuth(ctx, r.Data.ClientCredentials, clientChan)
|
||||
|
||||
tokenChan := make(chan *introspectionTokenResult)
|
||||
go s.introspectionToken(ctx, r.Data.Token, tokenChan)
|
||||
|
||||
var (
|
||||
client *instrospectionClientResult
|
||||
token *introspectionTokenResult
|
||||
)
|
||||
|
||||
// make sure both channels are always read,
|
||||
// and cancel the context on first error
|
||||
for i := 0; i < 2; i++ {
|
||||
var resErr error
|
||||
|
||||
select {
|
||||
case client = <-clientChan:
|
||||
resErr = client.err
|
||||
case token = <-tokenChan:
|
||||
resErr = token.err
|
||||
}
|
||||
|
||||
if resErr == nil {
|
||||
continue
|
||||
}
|
||||
cancel()
|
||||
|
||||
// we only care for the first error that occurred,
|
||||
// as the next error is most probably a context error.
|
||||
if err == nil {
|
||||
err = resErr
|
||||
}
|
||||
}
|
||||
|
||||
// only client auth errors should be returned
|
||||
var target *oidc.Error
|
||||
if errors.As(err, &target) && target.ErrorType == oidc.UnauthorizedClient {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// remaining errors shoudn't be returned to the client,
|
||||
// so we catch errors here, log them and return the response
|
||||
// with active: false
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.getLogger(ctx).ErrorContext(ctx, "oidc introspection", "err", err)
|
||||
resp, err = op.NewResponse(new(oidc.IntrospectionResponse)), nil
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: can we get rid of this separate query?
|
||||
if token.isPAT {
|
||||
if err = s.assertClientScopesForPAT(ctx, token.accessToken, client.clientID, client.projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = validateIntrospectionAudience(token.audience, client.clientID, client.projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userInfo, err := s.userInfo(ctx, token.userID, client.projectID, token.scope, []string{client.projectID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
introspectionResp := &oidc.IntrospectionResponse{
|
||||
Active: true,
|
||||
Scope: token.scope,
|
||||
ClientID: token.clientID,
|
||||
TokenType: oidc.BearerToken,
|
||||
Expiration: oidc.FromTime(token.tokenExpiration),
|
||||
IssuedAt: oidc.FromTime(token.tokenCreation),
|
||||
NotBefore: oidc.FromTime(token.tokenCreation),
|
||||
Audience: token.audience,
|
||||
Issuer: op.IssuerFromContext(ctx),
|
||||
JWTID: token.tokenID,
|
||||
}
|
||||
introspectionResp.SetUserInfo(userInfo)
|
||||
return op.NewResponse(introspectionResp), nil
|
||||
}
|
||||
|
||||
type instrospectionClientResult struct {
|
||||
clientID string
|
||||
projectID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Server) instrospectionClientAuth(ctx context.Context, cc *op.ClientCredentials, rc chan<- *instrospectionClientResult) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
|
||||
clientID, projectID, err := func() (string, string, error) {
|
||||
client, err := s.clientFromCredentials(ctx, cc)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if cc.ClientAssertion != "" {
|
||||
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, time.Second)
|
||||
if _, err := op.VerifyJWTAssertion(ctx, cc.ClientAssertion, verifier); err != nil {
|
||||
return "", "", oidc.ErrUnauthorizedClient().WithParent(err)
|
||||
}
|
||||
} else {
|
||||
if err := crypto.CompareHash(client.ClientSecret, []byte(cc.ClientSecret), s.hashAlg); err != nil {
|
||||
return "", "", oidc.ErrUnauthorizedClient().WithParent(err)
|
||||
}
|
||||
}
|
||||
|
||||
return client.ClientID, client.ProjectID, nil
|
||||
}()
|
||||
|
||||
span.EndWithError(err)
|
||||
|
||||
rc <- &instrospectionClientResult{
|
||||
clientID: clientID,
|
||||
projectID: projectID,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// clientFromCredentials parses the client ID early,
|
||||
// and makes a single query for the client for either auth methods.
|
||||
func (s *Server) clientFromCredentials(ctx context.Context, cc *op.ClientCredentials) (client *query.IntrospectionClient, err error) {
|
||||
if cc.ClientAssertion != "" {
|
||||
claims := new(oidc.JWTTokenRequest)
|
||||
if _, err := oidc.ParseToken(cc.ClientAssertion, claims); err != nil {
|
||||
return nil, oidc.ErrUnauthorizedClient().WithParent(err)
|
||||
}
|
||||
client, err = s.query.GetIntrospectionClientByID(ctx, claims.Issuer, true)
|
||||
} else {
|
||||
client, err = s.query.GetIntrospectionClientByID(ctx, cc.ClientID, false)
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, oidc.ErrUnauthorizedClient().WithParent(err)
|
||||
}
|
||||
// any other error is regarded internal and should not be reported back to the client.
|
||||
return client, err
|
||||
}
|
||||
|
||||
type introspectionTokenResult struct {
|
||||
*accessToken
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Server) introspectionToken(ctx context.Context, tkn string, rc chan<- *introspectionTokenResult) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
token, err := s.verifyAccessToken(ctx, tkn)
|
||||
span.EndWithError(err)
|
||||
|
||||
rc <- &introspectionTokenResult{
|
||||
accessToken: token,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func validateIntrospectionAudience(audience []string, clientID, projectID string) error {
|
||||
if slices.ContainsFunc(audience, func(entry string) bool {
|
||||
return entry == clientID || entry == projectID
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errz.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client")
|
||||
}
|
Reference in New Issue
Block a user