mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 18:17:35 +00:00
feat: JWT IdP intent (#9966)
# Which Problems Are Solved
The login v1 allowed to use JWTs as IdP using the JWT IDP. The login V2
uses idp intents for such cases, which were not yet able to handle JWT
IdPs.
# How the Problems Are Solved
- Added handling of JWT IdPs in `StartIdPIntent` and `RetrieveIdPIntent`
- The redirect returned by the start, uses the existing `authRequestID`
and `userAgentID` parameter names for compatibility reasons.
- Added `/idps/jwt` endpoint to handle the proxied (callback) endpoint ,
which extracts and validates the JWT against the configured endpoint.
# Additional Changes
None
# Additional Context
- closes #9758
(cherry picked from commit 4d66a786c8
)
This commit is contained in:
@@ -1859,6 +1859,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) {
|
||||
samlIdpID := Instance.AddSAMLProvider(IamCTX)
|
||||
samlRedirectIdpID := Instance.AddSAMLRedirectProvider(IamCTX, "")
|
||||
samlPostIdpID := Instance.AddSAMLPostProvider(IamCTX)
|
||||
jwtIdPID := Instance.AddJWTProvider(IamCTX)
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.StartIdentityProviderIntentRequest
|
||||
@@ -2081,6 +2082,30 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "next step jwt idp",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.StartIdentityProviderIntentRequest{
|
||||
IdpId: jwtIdPID,
|
||||
Content: &user.StartIdentityProviderIntentRequest_Urls{
|
||||
Urls: &user.RedirectURLs{
|
||||
SuccessUrl: "https://example.com/success",
|
||||
FailureUrl: "https://example.com/failure",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: want{
|
||||
details: &object.Details{
|
||||
ChangeDate: timestamppb.Now(),
|
||||
ResourceOwner: Instance.ID(),
|
||||
},
|
||||
url: "https://example.com/jwt",
|
||||
parametersExisting: []string{"authRequestID", "userAgentID"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -2118,6 +2143,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
|
||||
oidcIdpID := Instance.AddGenericOIDCProvider(IamCTX, gofakeit.AppName()).GetId()
|
||||
samlIdpID := Instance.AddSAMLPostProvider(IamCTX)
|
||||
ldapIdpID := Instance.AddLDAPProvider(IamCTX)
|
||||
jwtIdPID := Instance.AddJWTProvider(IamCTX)
|
||||
authURL, err := url.Parse(Instance.CreateIntent(CTX, oauthIdpID).GetAuthUrl())
|
||||
require.NoError(t, err)
|
||||
intentID := authURL.Query().Get("state")
|
||||
@@ -2152,6 +2178,10 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user", expiry)
|
||||
require.NoError(t, err)
|
||||
jwtSuccessfulID, jwtToken, jwtChangeDate, jwtSequence, err := sink.SuccessfulJWTIntent(Instance.ID(), jwtIdPID, "id", "", expiry)
|
||||
require.NoError(t, err)
|
||||
jwtSuccessfulWithUserID, jwtWithUserToken, jwtWithUserChangeDate, jwtWithUserSequence, err := sink.SuccessfulJWTIntent(Instance.ID(), jwtIdPID, "id", "user", expiry)
|
||||
require.NoError(t, err)
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.RetrieveIdentityProviderIntentRequest
|
||||
@@ -2575,6 +2605,88 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "retrieve successful jwt intent",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.RetrieveIdentityProviderIntentRequest{
|
||||
IdpIntentId: jwtSuccessfulID,
|
||||
IdpIntentToken: jwtToken,
|
||||
},
|
||||
},
|
||||
want: &user.RetrieveIdentityProviderIntentResponse{
|
||||
Details: &object.Details{
|
||||
ChangeDate: timestamppb.New(jwtChangeDate),
|
||||
ResourceOwner: Instance.ID(),
|
||||
Sequence: jwtSequence,
|
||||
},
|
||||
IdpInformation: &user.IDPInformation{
|
||||
Access: &user.IDPInformation_Oauth{
|
||||
Oauth: &user.IDPOAuthAccessInformation{
|
||||
IdToken: gu.Ptr("idToken"),
|
||||
},
|
||||
},
|
||||
IdpId: jwtIdPID,
|
||||
UserId: "id",
|
||||
UserName: "",
|
||||
RawInformation: func() *structpb.Struct {
|
||||
s, err := structpb.NewStruct(map[string]interface{}{
|
||||
"sub": "id",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}(),
|
||||
},
|
||||
AddHumanUser: &user.AddHumanUserRequest{
|
||||
Profile: &user.SetHumanProfile{
|
||||
PreferredLanguage: gu.Ptr("und"),
|
||||
},
|
||||
IdpLinks: []*user.IDPLink{
|
||||
{IdpId: jwtIdPID, UserId: "id"},
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Verification: &user.SetHumanEmail_SendCode{SendCode: &user.SendEmailVerificationCode{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "retrieve successful jwt intent with linked user",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.RetrieveIdentityProviderIntentRequest{
|
||||
IdpIntentId: jwtSuccessfulWithUserID,
|
||||
IdpIntentToken: jwtWithUserToken,
|
||||
},
|
||||
},
|
||||
want: &user.RetrieveIdentityProviderIntentResponse{
|
||||
Details: &object.Details{
|
||||
ChangeDate: timestamppb.New(jwtWithUserChangeDate),
|
||||
ResourceOwner: Instance.ID(),
|
||||
Sequence: jwtWithUserSequence,
|
||||
},
|
||||
IdpInformation: &user.IDPInformation{
|
||||
Access: &user.IDPInformation_Oauth{
|
||||
Oauth: &user.IDPOAuthAccessInformation{
|
||||
IdToken: gu.Ptr("idToken"),
|
||||
},
|
||||
},
|
||||
IdpId: jwtIdPID,
|
||||
UserId: "id",
|
||||
UserName: "",
|
||||
RawInformation: func() *structpb.Struct {
|
||||
s, err := structpb.NewStruct(map[string]interface{}{
|
||||
"sub": "id",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}(),
|
||||
},
|
||||
UserId: "user",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
@@ -173,7 +173,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R
|
||||
case *oidc.Provider:
|
||||
idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser())
|
||||
case *jwt.Provider:
|
||||
idpUser, err = unmarshalIdpUser(intent.IDPUser, &jwt.User{})
|
||||
idpUser, err = unmarshalIdpUser(intent.IDPUser, jwt.InitUser())
|
||||
case *azuread.Provider:
|
||||
idpUser, err = unmarshalRawIdpUser(intent.IDPUser, p.User())
|
||||
case *github.Provider:
|
||||
|
@@ -3,6 +3,7 @@ package idp
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -48,6 +49,7 @@ const (
|
||||
acsPath = idpPrefix + "/saml/acs"
|
||||
certificatePath = idpPrefix + "/saml/certificate"
|
||||
sloPath = idpPrefix + "/saml/slo"
|
||||
jwtPath = "/jwt"
|
||||
|
||||
paramIntentID = "id"
|
||||
paramToken = "token"
|
||||
@@ -129,6 +131,7 @@ func NewHandler(
|
||||
router.HandleFunc(certificatePath, h.handleCertificate)
|
||||
router.HandleFunc(acsPath, h.handleACS)
|
||||
router.HandleFunc(sloPath, h.handleSLO)
|
||||
router.HandleFunc(jwtPath, h.handleJWT)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -307,6 +310,89 @@ func (h *Handler) handleACS(w http.ResponseWriter, r *http.Request) {
|
||||
redirectToSuccessURL(w, r, intent, token, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) handleJWT(w http.ResponseWriter, r *http.Request) {
|
||||
intentID, err := h.intentIDFromJWTRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
intent, err := h.commands.GetActiveIntent(r.Context(), intentID)
|
||||
if err != nil {
|
||||
if zerrors.IsNotFound(err) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
redirectToFailureURLErr(w, r, intent, err)
|
||||
return
|
||||
}
|
||||
idpConfig, err := h.getProvider(r.Context(), intent.IDPID)
|
||||
if err != nil {
|
||||
cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error())
|
||||
logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent")
|
||||
redirectToFailureURLErr(w, r, intent, err)
|
||||
return
|
||||
}
|
||||
jwtIDP, ok := idpConfig.(*jwt.Provider)
|
||||
if !ok {
|
||||
err := zerrors.ThrowInvalidArgument(nil, "IDP-JK23ed", "Errors.ExternalIDP.IDPTypeNotImplemented")
|
||||
cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error())
|
||||
logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent")
|
||||
redirectToFailureURLErr(w, r, intent, err)
|
||||
return
|
||||
}
|
||||
h.handleJWTExtraction(w, r, intent, jwtIDP)
|
||||
}
|
||||
|
||||
func (h *Handler) intentIDFromJWTRequest(r *http.Request) (string, error) {
|
||||
// for compatibility of the old JWT provider we use the auth request id parameter to pass the intent id
|
||||
intentID := r.FormValue(jwt.QueryAuthRequestID)
|
||||
// for compatibility of the old JWT provider we use the user agent id parameter to pass the encrypted intent id
|
||||
encryptedIntentID := r.FormValue(jwt.QueryUserAgentID)
|
||||
if err := h.checkIntentID(intentID, encryptedIntentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return intentID, nil
|
||||
}
|
||||
|
||||
func (h *Handler) checkIntentID(intentID, encryptedIntentID string) error {
|
||||
if intentID == "" || encryptedIntentID == "" {
|
||||
return zerrors.ThrowInvalidArgument(nil, "LOGIN-adfzz", "Errors.AuthRequest.MissingParameters")
|
||||
}
|
||||
id, err := base64.RawURLEncoding.DecodeString(encryptedIntentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decryptedIntentID, err := h.encryptionAlgorithm.DecryptString(id, h.encryptionAlgorithm.EncryptionKeyID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if intentID != decryptedIntentID {
|
||||
return zerrors.ThrowInvalidArgument(nil, "LOGIN-adfzz", "Errors.AuthRequest.MissingParameters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleJWTExtraction(w http.ResponseWriter, r *http.Request, intent *command.IDPIntentWriteModel, identityProvider *jwt.Provider) {
|
||||
session := jwt.NewSessionFromRequest(identityProvider, r)
|
||||
user, err := session.FetchUser(r.Context())
|
||||
if err != nil {
|
||||
cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error())
|
||||
logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent")
|
||||
redirectToFailureURLErr(w, r, intent, err)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.checkExternalUser(r.Context(), intent.IDPID, user.GetID())
|
||||
logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists")
|
||||
|
||||
token, err := h.commands.SucceedIDPIntent(r.Context(), intent, user, session, userID)
|
||||
if err != nil {
|
||||
redirectToFailureURLErr(w, r, intent, err)
|
||||
return
|
||||
}
|
||||
redirectToSuccessURL(w, r, intent, token, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
data, err := h.parseCallbackRequest(r)
|
||||
|
Reference in New Issue
Block a user