232 lines
5.6 KiB
Go
232 lines
5.6 KiB
Go
package authentication
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/MicahParks/keyfunc"
|
|
"github.com/golang-jwt/jwt/v4"
|
|
)
|
|
|
|
const appleJWKSURL = "https://appleid.apple.com/auth/keys"
|
|
|
|
var (
|
|
appleJWKS *keyfunc.JWKS
|
|
appleJWKSOnce sync.Once
|
|
appleJWKSErr error
|
|
)
|
|
|
|
var ErrAppleSignInNotConfigured = errors.New("apple sign in is not configured")
|
|
|
|
func appleAllowedClientIDs(clientIDs string) []string {
|
|
parts := strings.Split(clientIDs, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if id := strings.TrimSpace(p); id != "" {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func getAppleJWKS() (*keyfunc.JWKS, error) {
|
|
appleJWKSOnce.Do(func() {
|
|
appleJWKS, appleJWKSErr = keyfunc.Get(appleJWKSURL, keyfunc.Options{
|
|
RefreshInterval: time.Hour,
|
|
RefreshTimeout: 10 * time.Second,
|
|
RefreshUnknownKID: true,
|
|
})
|
|
})
|
|
return appleJWKS, appleJWKSErr
|
|
}
|
|
|
|
type appleIdentityClaims struct {
|
|
jwt.RegisteredClaims
|
|
Email string `json:"email"`
|
|
EmailVerified any `json:"email_verified"`
|
|
IsPrivateEmail any `json:"is_private_email"`
|
|
}
|
|
|
|
func claimEmailVerified(v any) bool {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case string:
|
|
return strings.EqualFold(t, "true")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) ValidateAppleIdentityToken(
|
|
ctx context.Context,
|
|
idToken string,
|
|
allowedClientIDs []string,
|
|
) (domain.AppleUser, error) {
|
|
if len(allowedClientIDs) == 0 {
|
|
return domain.AppleUser{}, ErrAppleSignInNotConfigured
|
|
}
|
|
if idToken == "" {
|
|
return domain.AppleUser{}, errors.New("missing apple identity token")
|
|
}
|
|
|
|
jwks, err := getAppleJWKS()
|
|
if err != nil {
|
|
return domain.AppleUser{}, fmt.Errorf("failed to load apple jwks: %w", err)
|
|
}
|
|
|
|
token, err := jwt.ParseWithClaims(idToken, &appleIdentityClaims{}, jwks.Keyfunc)
|
|
if err != nil {
|
|
return domain.AppleUser{}, fmt.Errorf("invalid apple identity token: %w", err)
|
|
}
|
|
if !token.Valid {
|
|
return domain.AppleUser{}, errors.New("invalid apple identity token")
|
|
}
|
|
|
|
claims, ok := token.Claims.(*appleIdentityClaims)
|
|
if !ok {
|
|
return domain.AppleUser{}, errors.New("invalid apple token claims")
|
|
}
|
|
|
|
if claims.Issuer != "https://appleid.apple.com" {
|
|
return domain.AppleUser{}, errors.New("invalid apple token issuer")
|
|
}
|
|
|
|
audOK := false
|
|
for _, aud := range claims.Audience {
|
|
for _, allowed := range allowedClientIDs {
|
|
if aud == allowed {
|
|
audOK = true
|
|
break
|
|
}
|
|
}
|
|
if audOK {
|
|
break
|
|
}
|
|
}
|
|
if !audOK {
|
|
return domain.AppleUser{}, errors.New("apple token audience mismatch")
|
|
}
|
|
|
|
if claims.Subject == "" {
|
|
return domain.AppleUser{}, errors.New("apple token missing subject")
|
|
}
|
|
|
|
return domain.AppleUser{
|
|
ID: claims.Subject,
|
|
Email: strings.TrimSpace(claims.Email),
|
|
VerifiedEmail: claimEmailVerified(claims.EmailVerified),
|
|
}, nil
|
|
}
|
|
|
|
// LoginWithAppleMobile validates the identity token and signs the user in (iOS / Android / web credential).
|
|
func (s *Service) LoginWithAppleMobile(
|
|
ctx context.Context,
|
|
idToken string,
|
|
allowedClientIDs string,
|
|
profile domain.AppleUser,
|
|
) (domain.LoginSuccess, error) {
|
|
aUser, err := s.ValidateAppleIdentityToken(ctx, idToken, appleAllowedClientIDs(allowedClientIDs))
|
|
if err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
|
|
if aUser.Email == "" {
|
|
aUser.Email = strings.TrimSpace(profile.Email)
|
|
}
|
|
if aUser.GivenName == "" {
|
|
aUser.GivenName = strings.TrimSpace(profile.GivenName)
|
|
}
|
|
if aUser.FamilyName == "" {
|
|
aUser.FamilyName = strings.TrimSpace(profile.FamilyName)
|
|
}
|
|
if !aUser.VerifiedEmail && profile.VerifiedEmail {
|
|
aUser.VerifiedEmail = true
|
|
}
|
|
|
|
return s.LoginWithApple(ctx, aUser)
|
|
}
|
|
|
|
func (s *Service) LoginWithApple(
|
|
ctx context.Context,
|
|
aUser domain.AppleUser,
|
|
) (domain.LoginSuccess, error) {
|
|
if aUser.ID == "" {
|
|
return domain.LoginSuccess{}, errors.New("missing apple user id")
|
|
}
|
|
|
|
var user domain.User
|
|
var err error
|
|
|
|
user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID)
|
|
if err != nil {
|
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
|
|
if aUser.Email == "" {
|
|
return domain.LoginSuccess{}, errors.New("email is required on first sign in with apple")
|
|
}
|
|
|
|
user, err = s.userStore.GetUserByEmailPhone(ctx, aUser.Email, "")
|
|
if err != nil {
|
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
|
|
user, err = s.userStore.CreateAppleUser(ctx, aUser)
|
|
if err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
} else {
|
|
if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if user.Status == domain.UserStatusPending {
|
|
return domain.LoginSuccess{}, domain.ErrUserNotVerified
|
|
}
|
|
|
|
if user.Status == domain.UserStatusSuspended {
|
|
return domain.LoginSuccess{}, ErrUserSuspended
|
|
}
|
|
|
|
oldToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
|
|
if err != nil {
|
|
if !errors.Is(err, ErrRefreshTokenNotFound) {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
} else if !oldToken.Revoked {
|
|
if err := s.tokenStore.RevokeRefreshToken(ctx, oldToken.Token); err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
}
|
|
|
|
refreshToken, err := generateRefreshToken()
|
|
if err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
|
|
if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{
|
|
Token: refreshToken,
|
|
UserID: user.ID,
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second),
|
|
}); err != nil {
|
|
return domain.LoginSuccess{}, err
|
|
}
|
|
|
|
return domain.LoginSuccess{
|
|
UserId: user.ID,
|
|
Role: user.Role,
|
|
RfToken: refreshToken,
|
|
}, nil
|
|
}
|