Yimaru-BackEnd/internal/services/authentication/apple.go
2026-06-01 01:02:28 -07:00

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
}