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 }