chore: move the go code into a subfolder

This commit is contained in:
Florian Forster
2025-08-05 15:20:32 -07:00
parent 4ad22ba456
commit cd2921de26
2978 changed files with 373 additions and 300 deletions

View File

@@ -0,0 +1,68 @@
package apple
import (
"crypto/x509"
"encoding/pem"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/oidc/v3/pkg/crypto"
openid "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
const (
name = "Apple"
issuer = "https://appleid.apple.com"
)
var _ idp.Provider = (*Provider)(nil)
// Provider is the [idp.Provider] implementation for Apple
type Provider struct {
*oidc.Provider
}
func New(clientID, teamID, keyID, callbackURL string, key []byte, scopes []string, options ...oidc.ProviderOpts) (*Provider, error) {
secret, err := clientSecretFromPrivateKey(key, teamID, clientID, keyID)
if err != nil {
return nil, err
}
options = append(options, oidc.WithResponseMode("form_post"))
rp, err := oidc.New(name, issuer, clientID, secret, callbackURL, scopes, oidc.DefaultMapper, options...)
if err != nil {
return nil, err
}
return &Provider{
Provider: rp,
}, nil
}
// clientSecretFromPrivateKey uses the private key to create and sign a JWT, which has to be used as client_secret at Apple.
func clientSecretFromPrivateKey(key []byte, teamID, clientID, keyID string) (string, error) {
block, _ := pem.Decode(key)
b := block.Bytes
pk, err := x509.ParsePKCS8PrivateKey(b)
if err != nil {
return "", err
}
signingKey := jose.SigningKey{
Algorithm: jose.ES256,
Key: &jose.JSONWebKey{Key: pk, KeyID: keyID},
}
signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{})
if err != nil {
return "", err
}
iat := time.Now().Add(-2 * time.Second)
exp := iat.Add(time.Hour)
return crypto.Sign(&openid.JWTTokenRequest{
Issuer: teamID,
Subject: clientID,
Audience: []string{issuer},
ExpiresAt: openid.FromTime(exp),
IssuedAt: openid.FromTime(iat),
}, signer)
}

View File

@@ -0,0 +1,71 @@
package apple
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
const (
privateKey = `-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgXn/LDURaetCoymSj
fRslBiBwzBSa8ifiyfYGIWNStYGgCgYIKoZIzj0DAQehRANCAATymZXIsGrXnl6b
+80miSiVOCcLnyaYa2uQBQvQwgB7GibXhrzF+D/MRTV4P7P8+Lg1K9Khkjc59eNK
4RrQP4g7
-----END PRIVATE KEY-----
`
)
func TestProvider_BeginAuth(t *testing.T) {
type fields struct {
clientID string
teamID string
keyID string
privateKey []byte
redirectURI string
scopes []string
}
tests := []struct {
name string
fields fields
want idp.Session
}{
{
name: "successful auth",
fields: fields{
clientID: "clientID",
teamID: "teamID",
keyID: "keyID",
privateKey: []byte(privateKey),
redirectURI: "redirectURI",
scopes: []string{"openid"},
},
want: &Session{
Session: &oidc.Session{
AuthURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := assert.New(t)
r := require.New(t)
provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes)
r.NoError(err)
ctx := context.Background()
session, err := provider.BeginAuth(ctx, "testState")
r.NoError(err)
auth, err := session.GetAuth(ctx)
authExpected, errExpected := tt.want.GetAuth(ctx)
a.ErrorIs(err, errExpected)
a.Equal(authExpected, auth)
})
}
}

View File

@@ -0,0 +1,74 @@
package apple
import (
"context"
"encoding/json"
openid "github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
var _ idp.Session = (*Session)(nil)
// Session extends the [oidc.Session] with the formValues returned from the callback.
// This enables to parse the user (name and email), which Apple only returns as form params on registration
type Session struct {
*oidc.Session
UserFormValue string
}
func NewSession(provider *Provider, code, userFormValue string) *Session {
return &Session{Session: oidc.NewSession(provider.Provider, code, nil), UserFormValue: userFormValue}
}
type userFormValue struct {
Name userNamesFormValue `json:"name,omitempty" schema:"name"`
}
type userNamesFormValue struct {
FirstName string `json:"firstName,omitempty" schema:"firstName"`
LastName string `json:"lastName,omitempty" schema:"lastName"`
}
// FetchUser implements the [idp.Session] interface.
// It will execute an OIDC code exchange if needed to retrieve the tokens,
// extract the information from the id_token and if available also from the `user` form value.
// The information will be mapped into an [idp.User].
func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) {
if s.Tokens == nil {
if err = s.Authorize(ctx); err != nil {
return nil, err
}
}
info := s.Tokens.IDTokenClaims.GetUserInfo()
userName := userFormValue{}
if s.UserFormValue != "" {
if err = json.Unmarshal([]byte(s.UserFormValue), &userName); err != nil {
return nil, err
}
}
return NewUser(info, userName.Name), nil
}
func NewUser(info *openid.UserInfo, names userNamesFormValue) *User {
user := oidc.NewUser(info)
user.GivenName = names.FirstName
user.FamilyName = names.LastName
return &User{User: user}
}
func InitUser() idp.User {
return &User{User: oidc.InitUser()}
}
// User extends the [oidc.User] by returning the email as preferred_username, since Apple does not return the latter.
type User struct {
*oidc.User
}
func (u *User) GetPreferredUsername() string {
return u.Email
}

View File

@@ -0,0 +1,217 @@
package apple
import (
"context"
"errors"
"testing"
"time"
"github.com/h2non/gock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
openid "github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/oauth2"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/idp/providers/oidc"
)
func TestSession_FetchUser(t *testing.T) {
type fields struct {
clientID string
teamID string
keyID string
privateKey []byte
redirectURI string
scopes []string
httpMock func()
authURL string
code string
tokens *openid.Tokens[*openid.IDTokenClaims]
userFormValue string
}
type want struct {
err error
id string
firstName string
lastName string
displayName string
nickName string
preferredUsername string
email string
isEmailVerified bool
phone string
isPhoneVerified bool
preferredLanguage language.Tag
avatarURL string
profile string
nonceSupported bool
isPrivateEmail bool
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "unauthenticated session, error",
fields: fields{
clientID: "clientID",
teamID: "teamID",
keyID: "keyID",
privateKey: []byte(privateKey),
redirectURI: "redirectURI",
scopes: []string{"openid"},
httpMock: func() {},
authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState",
tokens: nil,
},
want: want{
err: oidc.ErrCodeMissing,
},
},
{
name: "no user param",
fields: fields{
clientID: "clientID",
teamID: "teamID",
keyID: "keyID",
privateKey: []byte(privateKey),
redirectURI: "redirectURI",
scopes: []string{"openid"},
httpMock: func() {},
authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState",
tokens: &openid.Tokens[*openid.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: openid.BearerToken,
},
IDTokenClaims: id_token(),
},
userFormValue: "",
},
want: want{
id: "sub",
firstName: "",
lastName: "",
displayName: "",
nickName: "",
preferredUsername: "email",
email: "email",
isEmailVerified: true,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "",
profile: "",
nonceSupported: true,
isPrivateEmail: true,
},
},
{
name: "with user param",
fields: fields{
clientID: "clientID",
teamID: "teamID",
keyID: "keyID",
privateKey: []byte(privateKey),
redirectURI: "redirectURI",
scopes: []string{"openid"},
httpMock: func() {},
authURL: "https://appleid.apple.com/auth/authorize?client_id=clientID&redirect_uri=redirectURI&response_mode=form_post&response_type=code&scope=openid&state=testState",
tokens: &openid.Tokens[*openid.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
TokenType: openid.BearerToken,
},
IDTokenClaims: id_token(),
},
userFormValue: `{"name": {"firstName": "firstName", "lastName": "lastName"}}`,
},
want: want{
id: "sub",
firstName: "firstName",
lastName: "lastName",
displayName: "",
nickName: "",
preferredUsername: "email",
email: "email",
isEmailVerified: true,
phone: "",
isPhoneVerified: false,
preferredLanguage: language.Und,
avatarURL: "",
profile: "",
nonceSupported: true,
isPrivateEmail: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
tt.fields.httpMock()
a := assert.New(t)
// call the real discovery endpoint
gock.New(issuer).Get(openid.DiscoveryEndpoint).EnableNetworking()
provider, err := New(tt.fields.clientID, tt.fields.teamID, tt.fields.keyID, tt.fields.redirectURI, tt.fields.privateKey, tt.fields.scopes)
require.NoError(t, err)
session := &Session{
Session: &oidc.Session{
Provider: provider.Provider,
AuthURL: tt.fields.authURL,
Code: tt.fields.code,
Tokens: tt.fields.tokens,
},
UserFormValue: tt.fields.userFormValue,
}
user, err := session.FetchUser(context.Background())
if tt.want.err != nil && !errors.Is(err, tt.want.err) {
a.Fail("invalid error", "expected %v, got %v", tt.want.err, err)
}
if tt.want.err == nil {
a.NoError(err)
a.Equal(tt.want.id, user.GetID())
a.Equal(tt.want.firstName, user.GetFirstName())
a.Equal(tt.want.lastName, user.GetLastName())
a.Equal(tt.want.displayName, user.GetDisplayName())
a.Equal(tt.want.nickName, user.GetNickname())
a.Equal(tt.want.preferredUsername, user.GetPreferredUsername())
a.Equal(domain.EmailAddress(tt.want.email), user.GetEmail())
a.Equal(tt.want.isEmailVerified, user.IsEmailVerified())
a.Equal(domain.PhoneNumber(tt.want.phone), user.GetPhone())
a.Equal(tt.want.isPhoneVerified, user.IsPhoneVerified())
a.Equal(tt.want.preferredLanguage, user.GetPreferredLanguage())
a.Equal(tt.want.avatarURL, user.GetAvatarURL())
a.Equal(tt.want.profile, user.GetProfile())
}
})
}
}
func id_token() *openid.IDTokenClaims {
return &openid.IDTokenClaims{
TokenClaims: openid.TokenClaims{
Issuer: issuer,
Subject: "sub",
Audience: []string{"clientID"},
Expiration: openid.FromTime(time.Now().Add(1 * time.Hour)),
IssuedAt: openid.FromTime(time.Now().Add(-1 * time.Second)),
AuthTime: openid.FromTime(time.Now().Add(-1 * time.Second)),
Nonce: "nonce",
ClientID: "clientID",
},
UserInfoEmail: openid.UserInfoEmail{
Email: "email",
EmailVerified: true,
},
Claims: map[string]any{
"nonce_supported": true,
"is_private_email": true,
},
}
}