mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 21:07:31 +00:00
feat(api): add OIDC session service (#6157)
This PR starts the OIDC implementation for the API V2 including the Implicit and Code Flow. Co-authored-by: Livio Spring <livio.a@gmail.com> Co-authored-by: Tim Möhlmann <tim+github@zitadel.com> Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2alpha"
|
||||
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/system"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
@@ -30,6 +31,7 @@ type Client struct {
|
||||
Mgmt mgmt.ManagementServiceClient
|
||||
UserV2 user.UserServiceClient
|
||||
SessionV2 session.SessionServiceClient
|
||||
OIDCv2 oidc_pb.OIDCServiceClient
|
||||
System system.SystemServiceClient
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ func newClient(cc *grpc.ClientConn) Client {
|
||||
Mgmt: mgmt.NewManagementServiceClient(cc),
|
||||
UserV2: user.NewUserServiceClient(cc),
|
||||
SessionV2: session.NewSessionServiceClient(cc),
|
||||
OIDCv2: oidc_pb.NewOIDCServiceClient(cc),
|
||||
System: system.NewSystemServiceClient(cc),
|
||||
}
|
||||
}
|
||||
@@ -62,11 +65,9 @@ func (t *Tester) UseIsolatedInstance(iamOwnerCtx, systemCtx context.Context) (pr
|
||||
}
|
||||
t.createClientConn(iamOwnerCtx, grpc.WithAuthority(primaryDomain))
|
||||
instanceId = instance.GetInstanceId()
|
||||
t.Users[instanceId] = map[UserType]User{
|
||||
IAMOwner: {
|
||||
Token: instance.GetPat(),
|
||||
},
|
||||
}
|
||||
t.Users.Set(instanceId, IAMOwner, &User{
|
||||
Token: instance.GetPat(),
|
||||
})
|
||||
return primaryDomain, instanceId, t.WithInstanceAuthorization(iamOwnerCtx, IAMOwner, instanceId)
|
||||
}
|
||||
|
||||
@@ -187,3 +188,34 @@ func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID s
|
||||
require.NoError(t, err)
|
||||
return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence
|
||||
}
|
||||
|
||||
func (s *Tester) CreatePasskeySession(t *testing.T, ctx context.Context, userID string) (id, token string, start, change time.Time) {
|
||||
createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{UserId: userID},
|
||||
},
|
||||
},
|
||||
Challenges: []session.ChallengeKind{
|
||||
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
|
||||
},
|
||||
Domain: s.Config.ExternalDomain,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assertion, err := s.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
updateResp, err := s.Client.SessionV2.SetSession(ctx, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
Checks: &session.Checks{
|
||||
Passkey: &session.CheckPasskey{
|
||||
CredentialAssertionData: assertion,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return createResp.GetSessionId(), updateResp.GetSessionToken(),
|
||||
createResp.GetDetails().GetChangeDate().AsTime(), updateResp.GetDetails().GetChangeDate().AsTime()
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
Log:
|
||||
Level: debug
|
||||
|
||||
ExternalSecure: false
|
||||
|
||||
TLS:
|
||||
Enabled: false
|
||||
|
||||
|
@@ -57,6 +57,7 @@ type UserType int
|
||||
const (
|
||||
Unspecified UserType = iota
|
||||
OrgOwner
|
||||
Login
|
||||
IAMOwner
|
||||
SystemUser // SystemUser is a user with access to the system service.
|
||||
)
|
||||
@@ -71,13 +72,29 @@ type User struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type InstanceUserMap map[string]map[UserType]*User
|
||||
|
||||
func (m InstanceUserMap) Set(instanceID string, typ UserType, user *User) {
|
||||
if m[instanceID] == nil {
|
||||
m[instanceID] = make(map[UserType]*User)
|
||||
}
|
||||
m[instanceID][typ] = user
|
||||
}
|
||||
|
||||
func (m InstanceUserMap) Get(instanceID string, typ UserType) *User {
|
||||
if users, ok := m[instanceID]; ok {
|
||||
return users[typ]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tester is a Zitadel server and client with all resources available for testing.
|
||||
type Tester struct {
|
||||
*start.Server
|
||||
|
||||
Instance authz.Instance
|
||||
Organisation *query.Org
|
||||
Users map[string]map[UserType]User
|
||||
Users InstanceUserMap
|
||||
|
||||
Client Client
|
||||
WebAuthN *webauthn.Client
|
||||
@@ -135,6 +152,7 @@ func (s *Tester) pollHealth(ctx context.Context) (err error) {
|
||||
}
|
||||
|
||||
const (
|
||||
LoginUser = "loginClient"
|
||||
MachineUser = "integration"
|
||||
)
|
||||
|
||||
@@ -148,10 +166,9 @@ func (s *Tester) createMachineUser(ctx context.Context, instanceId string) {
|
||||
s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID())
|
||||
logging.OnError(err).Fatal("query organisation")
|
||||
|
||||
query, err := query.NewUserUsernameSearchQuery(MachineUser, query.TextEquals)
|
||||
usernameQuery, err := query.NewUserUsernameSearchQuery(MachineUser, query.TextEquals)
|
||||
logging.OnError(err).Fatal("user query")
|
||||
user, err := s.Queries.GetUser(ctx, true, true, query)
|
||||
|
||||
user, err := s.Queries.GetUser(ctx, true, true, usernameQuery)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = s.Commands.AddMachine(ctx, &command.Machine{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
@@ -162,11 +179,10 @@ func (s *Tester) createMachineUser(ctx context.Context, instanceId string) {
|
||||
Description: "who cares?",
|
||||
AccessTokenType: domain.OIDCTokenTypeJWT,
|
||||
})
|
||||
logging.OnError(err).Fatal("add machine user")
|
||||
user, err = s.Queries.GetUser(ctx, true, true, query)
|
||||
|
||||
logging.WithFields("username", SystemUser).OnError(err).Fatal("add machine user")
|
||||
user, err = s.Queries.GetUser(ctx, true, true, usernameQuery)
|
||||
}
|
||||
logging.OnError(err).Fatal("get user")
|
||||
logging.WithFields("username", SystemUser).OnError(err).Fatal("get user")
|
||||
|
||||
_, err = s.Commands.AddOrgMember(ctx, s.Organisation.ID, user.ID, "ORG_OWNER")
|
||||
target := new(caos_errs.AlreadyExistsError)
|
||||
@@ -177,18 +193,50 @@ func (s *Tester) createMachineUser(ctx context.Context, instanceId string) {
|
||||
scopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner}
|
||||
pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine)
|
||||
_, err = s.Commands.AddPersonalAccessToken(ctx, pat)
|
||||
logging.OnError(err).Fatal("add pat")
|
||||
|
||||
if s.Users == nil {
|
||||
s.Users = make(map[string]map[UserType]User)
|
||||
}
|
||||
if s.Users[instanceId] == nil {
|
||||
s.Users[instanceId] = make(map[UserType]User)
|
||||
}
|
||||
s.Users[instanceId][OrgOwner] = User{
|
||||
logging.WithFields("username", SystemUser).OnError(err).Fatal("add pat")
|
||||
s.Users.Set(instanceId, OrgOwner, &User{
|
||||
User: user,
|
||||
Token: pat.Token,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Tester) createLoginClient(ctx context.Context) {
|
||||
var err error
|
||||
|
||||
s.Instance, err = s.Queries.InstanceByHost(ctx, s.Host())
|
||||
logging.OnError(err).Fatal("query instance")
|
||||
ctx = authz.WithInstance(ctx, s.Instance)
|
||||
|
||||
s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID())
|
||||
logging.OnError(err).Fatal("query organisation")
|
||||
|
||||
usernameQuery, err := query.NewUserUsernameSearchQuery(LoginUser, query.TextEquals)
|
||||
logging.WithFields("username", LoginUser).OnError(err).Fatal("user query")
|
||||
user, err := s.Queries.GetUser(ctx, true, true, usernameQuery)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = s.Commands.AddMachine(ctx, &command.Machine{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
ResourceOwner: s.Organisation.ID,
|
||||
},
|
||||
Username: LoginUser,
|
||||
Name: LoginUser,
|
||||
Description: "who cares?",
|
||||
AccessTokenType: domain.OIDCTokenTypeJWT,
|
||||
})
|
||||
logging.WithFields("username", LoginUser).OnError(err).Fatal("add machine user")
|
||||
user, err = s.Queries.GetUser(ctx, true, true, usernameQuery)
|
||||
}
|
||||
logging.WithFields("username", LoginUser).OnError(err).Fatal("get user")
|
||||
|
||||
scopes := []string{oidc.ScopeOpenID, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner}
|
||||
pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine)
|
||||
_, err = s.Commands.AddPersonalAccessToken(ctx, pat)
|
||||
logging.OnError(err).Fatal("add pat")
|
||||
|
||||
s.Users.Set(FirstInstanceUsersKey, Login, &User{
|
||||
User: user,
|
||||
Token: pat.Token,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Tester) WithAuthorization(ctx context.Context, u UserType) context.Context {
|
||||
@@ -199,15 +247,12 @@ func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, inst
|
||||
if u == SystemUser {
|
||||
s.ensureSystemUser()
|
||||
}
|
||||
return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users[instanceID][u].Token))
|
||||
return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users.Get(instanceID, u).Token))
|
||||
}
|
||||
|
||||
func (s *Tester) ensureSystemUser() {
|
||||
const ISSUER = "tester"
|
||||
if s.Users[FirstInstanceUsersKey] == nil {
|
||||
s.Users[FirstInstanceUsersKey] = make(map[UserType]User)
|
||||
}
|
||||
if _, ok := s.Users[FirstInstanceUsersKey][SystemUser]; ok {
|
||||
if s.Users.Get(FirstInstanceUsersKey, SystemUser) != nil {
|
||||
return
|
||||
}
|
||||
audience := http_util.BuildOrigin(s.Host(), s.Server.Config.ExternalSecure)
|
||||
@@ -215,7 +260,11 @@ func (s *Tester) ensureSystemUser() {
|
||||
logging.OnError(err).Fatal("system key signer")
|
||||
jwt, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer)
|
||||
logging.OnError(err).Fatal("system key jwt")
|
||||
s.Users[FirstInstanceUsersKey][SystemUser] = User{Token: jwt}
|
||||
s.Users.Set(FirstInstanceUsersKey, SystemUser, &User{Token: jwt})
|
||||
}
|
||||
|
||||
func (s *Tester) WithSystemAuthorizationHTTP(u UserType) map[string]string {
|
||||
return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.Users.Get(FirstInstanceUsersKey, u).Token)}
|
||||
}
|
||||
|
||||
// Done send an interrupt signal to cleanly shutdown the server.
|
||||
@@ -263,9 +312,7 @@ func NewTester(ctx context.Context) *Tester {
|
||||
logging.OnError(err).Fatal()
|
||||
|
||||
tester := Tester{
|
||||
Users: map[string]map[UserType]User{
|
||||
FirstInstanceUsersKey: make(map[UserType]User),
|
||||
},
|
||||
Users: make(InstanceUserMap),
|
||||
}
|
||||
tester.wg.Add(1)
|
||||
go func(wg *sync.WaitGroup) {
|
||||
@@ -279,6 +326,8 @@ func NewTester(ctx context.Context) *Tester {
|
||||
logging.OnError(ctx.Err()).Fatal("waiting for integration tester server")
|
||||
}
|
||||
tester.createClientConn(ctx)
|
||||
tester.createLoginClient(ctx)
|
||||
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(tester.Host(), tester.Config.ExternalSecure))
|
||||
tester.createMachineUser(ctx, FirstInstanceUsersKey)
|
||||
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host())
|
||||
|
||||
|
163
internal/integration/oidc.go
Normal file
163
internal/integration/oidc.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/v2/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/app"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
)
|
||||
|
||||
func (s *Tester) CreateOIDCNativeClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) {
|
||||
project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{
|
||||
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{
|
||||
ProjectId: project.GetId(),
|
||||
Name: fmt.Sprintf("app-%d", time.Now().UnixNano()),
|
||||
RedirectUris: []string{redirectURI},
|
||||
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE},
|
||||
GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN},
|
||||
AppType: app.OIDCAppType_OIDC_APP_TYPE_NATIVE,
|
||||
AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
PostLogoutRedirectUris: nil,
|
||||
Version: app.OIDCVersion_OIDC_VERSION_1_0,
|
||||
DevMode: false,
|
||||
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
|
||||
AccessTokenRoleAssertion: false,
|
||||
IdTokenRoleAssertion: false,
|
||||
IdTokenUserinfoAssertion: false,
|
||||
ClockSkew: nil,
|
||||
AdditionalOrigins: nil,
|
||||
SkipNativeAppSuccessPage: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) {
|
||||
project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{
|
||||
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{
|
||||
ProjectId: project.GetId(),
|
||||
Name: fmt.Sprintf("app-%d", time.Now().UnixNano()),
|
||||
RedirectUris: []string{redirectURI},
|
||||
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN},
|
||||
GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT},
|
||||
AppType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT,
|
||||
AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
PostLogoutRedirectUris: nil,
|
||||
Version: app.OIDCVersion_OIDC_VERSION_1_0,
|
||||
DevMode: true,
|
||||
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
|
||||
AccessTokenRoleAssertion: false,
|
||||
IdTokenRoleAssertion: false,
|
||||
IdTokenUserinfoAssertion: false,
|
||||
ClockSkew: nil,
|
||||
AdditionalOrigins: nil,
|
||||
SkipNativeAppSuccessPage: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Tester) CreateOIDCAuthRequest(clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) {
|
||||
provider, err := s.CreateRelyingParty(clientID, redirectURI, scope...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
codeVerifier := "codeVerifier"
|
||||
codeChallenge := oidc.NewSHACodeChallenge(codeVerifier)
|
||||
authURL := rp.AuthURL("state", provider, rp.WithCodeChallenge(codeChallenge))
|
||||
|
||||
loc, err := CheckRedirect(authURL, map[string]string{oidc_internal.LoginClientHeader: loginClient})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
prefixWithHost := provider.Issuer() + s.Config.OIDC.DefaultLoginURLV2
|
||||
if !strings.HasPrefix(loc.String(), prefixWithHost) {
|
||||
return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String())
|
||||
}
|
||||
return strings.TrimPrefix(loc.String(), prefixWithHost), nil
|
||||
}
|
||||
|
||||
func (s *Tester) CreateOIDCAuthRequestImplicit(clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) {
|
||||
provider, err := s.CreateRelyingParty(clientID, redirectURI, scope...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
authURL := rp.AuthURL("state", provider)
|
||||
|
||||
// implicit is not natively supported so let's just overwrite the response type
|
||||
parsed, _ := url.Parse(authURL)
|
||||
queries := parsed.Query()
|
||||
queries.Set("response_type", string(oidc.ResponseTypeIDToken))
|
||||
parsed.RawQuery = queries.Encode()
|
||||
authURL = parsed.String()
|
||||
|
||||
loc, err := CheckRedirect(authURL, map[string]string{oidc_internal.LoginClientHeader: loginClient})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
prefixWithHost := provider.Issuer() + s.Config.OIDC.DefaultLoginURLV2
|
||||
if !strings.HasPrefix(loc.String(), prefixWithHost) {
|
||||
return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String())
|
||||
}
|
||||
return strings.TrimPrefix(loc.String(), prefixWithHost), nil
|
||||
}
|
||||
|
||||
func (s *Tester) CreateRelyingParty(clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) {
|
||||
issuer := http_util.BuildHTTP(s.Config.ExternalDomain, s.Config.Port, s.Config.ExternalSecure)
|
||||
if len(scope) == 0 {
|
||||
scope = []string{oidc.ScopeOpenID}
|
||||
}
|
||||
return rp.NewRelyingPartyOIDC(issuer, clientID, "", redirectURI, scope)
|
||||
}
|
||||
|
||||
func CheckRedirect(url string, headers map[string]string) (*url.URL, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.Location()
|
||||
}
|
||||
|
||||
func (s *Tester) CreateSession(ctx context.Context, userID string) (string, string, error) {
|
||||
session, err := s.Commands.CreateSession(ctx, []command.SessionCommand{command.CheckUser(userID)}, "domain.tld", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return session.ID, session.NewToken, nil
|
||||
}
|
Reference in New Issue
Block a user