feat(oidc): optimize the userinfo endpoint (#7706)

* feat(oidc): optimize the userinfo endpoint

* store project ID in the access token

* query for projectID if not in token

* add scope based tests

* Revert "store project ID in the access token"

This reverts commit 5f0262f239.

* query project role assertion

* use project role assertion setting to return roles

* workaround eventual consistency and handle PAT

* do not append empty project id
This commit is contained in:
Tim Möhlmann
2024-04-09 16:15:35 +03:00
committed by GitHub
parent c8e0b30e17
commit 6a51c4b0f5
25 changed files with 528 additions and 159 deletions

View File

@@ -41,7 +41,7 @@ func (a *AuthRequest) checkLoginClient(ctx context.Context) error {
return nil
}
//go:embed embed/auth_request_by_id.sql
//go:embed auth_request_by_id.sql
var authRequestByIDQuery string
func (q *Queries) authRequestByIDQuery(ctx context.Context) string {

View File

@@ -25,6 +25,8 @@ var introspectionTriggerHandlers = sync.OnceValue(func() []*handler.Handler {
)
})
// TriggerIntrospectionProjections triggers all projections
// relevant to introspection queries concurrently.
func TriggerIntrospectionProjections(ctx context.Context) {
triggerBatch(ctx, introspectionTriggerHandlers()...)
}
@@ -37,16 +39,17 @@ const (
)
type IntrospectionClient struct {
AppID string
ClientID string
HashedSecret string
AppType AppType
ProjectID string
ResourceOwner string
PublicKeys database.Map[[]byte]
AppID string
ClientID string
HashedSecret string
AppType AppType
ProjectID string
ResourceOwner string
ProjectRoleAssertion bool
PublicKeys database.Map[[]byte]
}
//go:embed embed/introspection_client_by_id.sql
//go:embed introspection_client_by_id.sql
var introspectionClientByIDQuery string
func (q *Queries) GetIntrospectionClientByID(ctx context.Context, clientID string, getKeys bool) (_ *IntrospectionClient, err error) {
@@ -66,6 +69,7 @@ func (q *Queries) GetIntrospectionClientByID(ctx context.Context, clientID strin
&client.AppType,
&client.ProjectID,
&client.ResourceOwner,
&client.ProjectRoleAssertion,
&client.PublicKeys,
)
},

View File

@@ -1,10 +1,10 @@
with config as (
select app_id, client_id, client_secret, 'api' as app_type
select instance_id, app_id, client_id, client_secret, 'api' as app_type
from projections.apps7_api_configs
where instance_id = $1
and client_id = $2
union
select app_id, client_id, client_secret, 'oidc' as app_type
select instance_id, app_id, client_id, client_secret, 'oidc' as app_type
from projections.apps7_oidc_configs
where instance_id = $1
and client_id = $2
@@ -18,7 +18,8 @@ keys as (
and expiration > current_timestamp
group by identifier
)
select config.app_id, config.client_id, config.client_secret, config.app_type, apps.project_id, apps.resource_owner, keys.public_keys
select config.app_id, config.client_id, config.client_secret, config.app_type, apps.project_id, apps.resource_owner, p.project_role_assertion, keys.public_keys
from config
join projections.apps7 apps on apps.id = config.app_id
join projections.apps7 apps on apps.id = config.app_id and apps.instance_id = config.instance_id
join projections.projects4 p on p.id = apps.project_id and p.instance_id = $1
left join keys on keys.client_id = config.client_id;

View File

@@ -50,17 +50,18 @@ func TestQueries_GetIntrospectionClientByID(t *testing.T) {
getKeys: false,
},
mock: mockQuery(expQuery,
[]string{"app_id", "client_id", "client_secret", "app_type", "project_id", "resource_owner", "public_keys"},
[]driver.Value{"appID", "clientID", "secret", "oidc", "projectID", "orgID", nil},
[]string{"app_id", "client_id", "client_secret", "app_type", "project_id", "resource_owner", "project_role_assertion", "public_keys"},
[]driver.Value{"appID", "clientID", "secret", "oidc", "projectID", "orgID", true, nil},
"instanceID", "clientID", false),
want: &IntrospectionClient{
AppID: "appID",
ClientID: "clientID",
HashedSecret: "secret",
AppType: AppTypeOIDC,
ProjectID: "projectID",
ResourceOwner: "orgID",
PublicKeys: nil,
AppID: "appID",
ClientID: "clientID",
HashedSecret: "secret",
AppType: AppTypeOIDC,
ProjectID: "projectID",
ResourceOwner: "orgID",
ProjectRoleAssertion: true,
PublicKeys: nil,
},
},
{
@@ -70,17 +71,18 @@ func TestQueries_GetIntrospectionClientByID(t *testing.T) {
getKeys: true,
},
mock: mockQuery(expQuery,
[]string{"app_id", "client_id", "client_secret", "app_type", "project_id", "resource_owner", "public_keys"},
[]driver.Value{"appID", "clientID", "", "oidc", "projectID", "orgID", encPubkeys},
[]string{"app_id", "client_id", "client_secret", "app_type", "project_id", "resource_owner", "project_role_assertion", "public_keys"},
[]driver.Value{"appID", "clientID", "", "oidc", "projectID", "orgID", true, encPubkeys},
"instanceID", "clientID", true),
want: &IntrospectionClient{
AppID: "appID",
ClientID: "clientID",
HashedSecret: "",
AppType: AppTypeOIDC,
ProjectID: "projectID",
ResourceOwner: "orgID",
PublicKeys: pubkeys,
AppID: "appID",
ClientID: "clientID",
HashedSecret: "",
AppType: AppTypeOIDC,
ProjectID: "projectID",
ResourceOwner: "orgID",
ProjectRoleAssertion: true,
PublicKeys: pubkeys,
},
},
}

View File

@@ -35,11 +35,12 @@ type OIDCClient struct {
AdditionalOrigins []string `json:"additional_origins,omitempty"`
PublicKeys map[string][]byte `json:"public_keys,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ProjectRoleAssertion bool `json:"project_role_assertion,omitempty"`
ProjectRoleKeys []string `json:"project_role_keys,omitempty"`
Settings *OIDCSettings `json:"settings,omitempty"`
}
//go:embed embed/oidc_client_by_id.sql
//go:embed oidc_client_by_id.sql
var oidcClientQuery string
func (q *Queries) GetOIDCClientByID(ctx context.Context, clientID string, getKeys bool) (client *OIDCClient, err error) {

View File

@@ -1,14 +1,12 @@
--deallocate q;
--prepare q(text, text, boolean) as
with client as (
select c.instance_id,
c.app_id, c.client_id, c.client_secret, c.redirect_uris, c.response_types, c.grant_types,
c.app_id, a.state, c.client_id, c.client_secret, c.redirect_uris, c.response_types, c.grant_types,
c.application_type, c.auth_method_type, c.post_logout_redirect_uris, c.is_dev_mode,
c.access_token_type, c.access_token_role_assertion, c.id_token_role_assertion,
c.id_token_userinfo_assertion, c.clock_skew, c.additional_origins, a.project_id, a.state
c.id_token_userinfo_assertion, c.clock_skew, c.additional_origins, a.project_id, p.project_role_assertion
from projections.apps7_oidc_configs c
join projections.apps7 a on a.id = c.app_id and a.instance_id = c.instance_id
join projections.projects4 p on p.id = a.project_id and p.instance_id = a.instance_id
where c.instance_id = $1
and c.client_id = $2
),
@@ -45,7 +43,5 @@ select row_to_json(r) as client from (
from client c
left join roles r on r.project_id = c.project_id
left join keys k on k.client_id = c.client_id
left join settings s on s.instance_id = s.instance_id
left join settings s on s.instance_id = c.instance_id
) r;
--execute q('230690539048009730', '236647088211951618@tests', true);

View File

@@ -80,6 +80,7 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx
ClockSkew: 1000000000,
AdditionalOrigins: []string{"https://example.com"},
ProjectID: "236645808328409090",
ProjectRoleAssertion: true,
PublicKeys: map[string][]byte{"236647201860747266": []byte(pubkey)},
ProjectRoleKeys: []string{"role1", "role2"},
Settings: &OIDCSettings{
@@ -112,6 +113,7 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx
AdditionalOrigins: nil,
PublicKeys: nil,
ProjectID: "236645808328409090",
ProjectRoleAssertion: true,
ProjectRoleKeys: []string{"role1", "role2"},
Settings: &OIDCSettings{
AccessTokenLifetime: 43200000000000,
@@ -143,6 +145,7 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx
AdditionalOrigins: nil,
PublicKeys: nil,
ProjectID: "236645808328409090",
ProjectRoleAssertion: false,
ProjectRoleKeys: []string{"role1", "role2"},
Settings: &OIDCSettings{
AccessTokenLifetime: 43200000000000,
@@ -179,6 +182,7 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx
AdditionalOrigins: nil,
PublicKeys: nil,
ProjectID: "239520764276178946",
ProjectRoleAssertion: false,
ProjectRoleKeys: nil,
Settings: nil,
},

View File

@@ -1,6 +1,7 @@
{
"instance_id": "230690539048009730",
"app_id": "236647088211886082",
"state": 1,
"client_id": "236647088211951618@tests",
"client_secret": null,
"redirect_uris": ["http://localhost:9999/auth/callback"],
@@ -17,7 +18,7 @@
"clock_skew": 1000000000,
"additional_origins": ["https://example.com"],
"project_id": "236645808328409090",
"state": 1,
"project_role_assertion": true,
"project_role_keys": ["role1", "role2"],
"public_keys": {
"236647201860747266": "LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFB\nT0NBUThBTUlJQkNnS0NBUUVBMnVmQUwxYjcyYkl5MWFyK1dzNmIKR29oSkpRRkI3ZGZSYXBEcWVx\nTThVa3A2Q1ZkUHpxL3BPejF2aUFxNTB5eldaSnJ5Risyd3NoRkFLR0Y5QTIvQgoyWWY5YkpYUFov\nS2JrRnJZVDNOVHZZRGt2bGFTVGw5bU1uenJVMjlzNDhGMVBUV0tmQitDM2FNc09FRzFCdWZWCnM2\nM3FGNG5yRVBqU2JobGpJY285RlpxNFhwcEl6aE1RMGZEZEEvK1h5Z0NKcXZ1YUwwTGliTTFLcmxV\nZG51NzEKWWVraFNKakVQbnZPaXNYSWs0SVh5d29HSU93dGp4a0R2Tkl0UXZhTVZsZHI0L2tiNnV2\nYmdkV3dxNUV3QlpYcQpsb3cya3lKb3YzOFY0VWsySThrdVhwTGNucnB3NVRpbzJvb2lVRTI3YjB2\nSFpxQktPZWk5VW84OHFDcm4zRUt4CjZRSURBUUFCCi0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0t\nLS0K"

View File

@@ -1,6 +1,7 @@
{
"instance_id": "239520764275982338",
"app_id": "239520764276441090",
"state": 1,
"client_id": "239520764779364354@zitadel",
"client_secret": null,
"redirect_uris": [
@@ -23,7 +24,7 @@
"clock_skew": 0,
"additional_origins": null,
"project_id": "239520764276178946",
"state": 1,
"project_role_assertion": false,
"project_role_keys": null,
"public_keys": null,
"settings": null

View File

@@ -1,6 +1,7 @@
{
"instance_id": "230690539048009730",
"app_id": "236646457053020162",
"state": 1,
"client_id": "236646457053085698@tests",
"client_secret": null,
"redirect_uris": ["http://localhost:9999/auth/callback"],
@@ -17,7 +18,7 @@
"clock_skew": 0,
"additional_origins": null,
"project_id": "236645808328409090",
"state": 1,
"project_role_assertion": true,
"project_role_keys": ["role1", "role2"],
"public_keys": null,
"settings": {

View File

@@ -1,6 +1,7 @@
{
"instance_id": "230690539048009730",
"app_id": "236646858984783874",
"state": 1,
"client_id": "236646858984849410@tests",
"client_secret": "$2a$14$OzZ0XEZZEtD13py/EPba2evsS6WcKZ5orVMj9pWHEGEHmLu2h3PFq",
"redirect_uris": ["http://localhost:9999/auth/callback"],
@@ -17,7 +18,7 @@
"clock_skew": 0,
"additional_origins": null,
"project_id": "236645808328409090",
"state": 1,
"project_role_assertion": false,
"project_role_keys": ["role1", "role2"],
"public_keys": null,
"settings": {

View File

@@ -29,11 +29,13 @@ var oidcUserInfoTriggerHandlers = sync.OnceValue(func() []*handler.Handler {
}
})
// TriggerOIDCUserInfoProjections triggers all projections
// relevant to userinfo queries concurrently.
func TriggerOIDCUserInfoProjections(ctx context.Context) {
triggerBatch(ctx, oidcUserInfoTriggerHandlers()...)
}
//go:embed embed/userinfo_by_id.sql
//go:embed userinfo_by_id.sql
var oidcUserInfoQuery string
func (q *Queries) GetOIDCUserInfo(ctx context.Context, userID string, roleAudience []string) (_ *OIDCUserInfo, err error) {
@@ -68,3 +70,25 @@ type UserInfoOrg struct {
Name string `json:"name,omitempty"`
PrimaryDomain string `json:"primary_domain,omitempty"`
}
//go:embed userinfo_client_by_id.sql
var oidcUserinfoClientQuery string
func (q *Queries) GetOIDCUserinfoClientByID(ctx context.Context, clientID string) (projectID string, projectRoleAssertion bool, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
scan := func(row *sql.Row) error {
err := row.Scan(&projectID, &projectRoleAssertion)
return err
}
err = q.client.QueryRowContext(ctx, scan, oidcUserinfoClientQuery, authz.GetInstance(ctx).InstanceID(), clientID)
if errors.Is(err, sql.ErrNoRows) {
return "", false, zerrors.ThrowNotFound(err, "QUERY-beeW8", "Errors.App.NotFound")
}
if err != nil {
return "", false, zerrors.ThrowInternal(err, "QUERY-Ais4r", "Errors.Internal")
}
return projectID, projectRoleAssertion, nil
}

View File

@@ -0,0 +1,6 @@
select a.project_id, p.project_role_assertion
from projections.apps7_oidc_configs c
join projections.apps7 a on a.id = c.app_id and a.instance_id = c.instance_id
join projections.projects4 p on p.id = a.project_id and p.instance_id = a.instance_id
where c.instance_id = $1
and c.client_id = $2;

View File

@@ -338,3 +338,50 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
})
}
}
func TestQueries_GetOIDCUserinfoClientByID(t *testing.T) {
expQuery := regexp.QuoteMeta(oidcUserinfoClientQuery)
cols := []string{"project_id", "project_role_assertion"}
tests := []struct {
name string
mock sqlExpectation
wantProjectID string
wantProjectRoleAssertion bool
wantErr error
}{
{
name: "no rows",
mock: mockQueryErr(expQuery, sql.ErrNoRows, "instanceID", "clientID"),
wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-beeW8", "Errors.App.NotFound"),
},
{
name: "internal error",
mock: mockQueryErr(expQuery, sql.ErrConnDone, "instanceID", "clientID"),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ais4r", "Errors.Internal"),
},
{
name: "found",
mock: mockQuery(expQuery, cols, []driver.Value{"projectID", true}, "instanceID", "clientID"),
wantProjectID: "projectID",
wantProjectRoleAssertion: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.mock, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
}
ctx := authz.NewMockContext("instanceID", "orgID", "loginClient")
gotProjectID, gotProjectRoleAssertion, err := q.GetOIDCUserinfoClientByID(ctx, "clientID")
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantProjectID, gotProjectID)
assert.Equal(t, tt.wantProjectRoleAssertion, gotProjectRoleAssertion)
})
})
}
}