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:
Livio Spring
2023-07-10 15:27:00 +02:00
committed by GitHub
parent be1fe36776
commit 14b8cf4894
69 changed files with 5948 additions and 106 deletions

View File

@@ -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()
}

View File

@@ -1,6 +1,8 @@
Log:
Level: debug
ExternalSecure: false
TLS:
Enabled: false

View File

@@ -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())

View 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
}