diff --git a/internal/api/api.go b/internal/api/api.go index aafcff22f3..989ae87800 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -192,6 +192,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/", api.Reauthenticate) }) + r.With(api.requireAuthentication).Route("/user", func(r *router) { r.Get("/", api.UserGet) r.With(api.limitHandler(api.limiterOpts.User)).Put("/", api.UserUpdate) @@ -201,6 +202,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/authorize", api.LinkIdentity) r.Delete("/{identity_id}", api.DeleteIdentity) }) + r.Post("/identities/link_token", api.LinkIdentityWithIDToken) }) r.With(api.requireAuthentication).Route("/factors", func(r *router) { diff --git a/internal/api/external_apple_link_test.go b/internal/api/external_apple_link_test.go new file mode 100644 index 0000000000..09c5b3e5c8 --- /dev/null +++ b/internal/api/external_apple_link_test.go @@ -0,0 +1,179 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/models" +) + +func (ts *ExternalTestSuite) TestLinkIdentityWithIDToken_Apple() { + // Setup user to link with + existingUser, err := ts.createUser("existing123", "existing@example.com", "Existing User", "", "") + ts.Require().NoError(err) + + // Create a session for the user + session, err := models.NewSession(existingUser.ID, nil) + ts.Require().NoError(err) + ts.Require().NoError(ts.API.db.Create(session)) + + // Generate access token for authentication + token, _, err := ts.API.generateAccessToken(httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil), ts.API.db, existingUser, &session.ID, models.PasswordGrant) + ts.Require().NoError(err) + + // Simulated Apple ID token data + appleUserData := &provider.UserProvidedData{ + Emails: []provider.Email{ + { + Email: "apple@example.com", + Verified: true, + Primary: true, + }, + }, + Metadata: &provider.Claims{ + Subject: "apple123", + Name: "Apple Test User", + }, + } + + // Simulate an Apple ID token by encoding user data + idToken := ts.mockAppleIDToken(appleUserData) + + // Create the request + reqData := map[string]interface{}{ + "id_token": idToken, + "provider": "apple", + } + reqBody, err := json.Marshal(reqData) + ts.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/user/identities/link_token", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + ts.Require().Equal(http.StatusOK, w.Code) + + // Verify the user now has the linked identity + updatedUser, err := models.FindUserByID(ts.API.db, existingUser.ID) + ts.Require().NoError(err) + + // Load identities + ts.API.db.Load(updatedUser, "Identities") + + // Verify that the new Apple identity was added + var found bool + for _, identity := range updatedUser.Identities { + if identity.Provider == "apple" && identity.ProviderID == "apple123" { + found = true + // Verify identity data + ts.Equal("Apple Test User", identity.IdentityData["name"]) + break + } + } + ts.True(found, "Apple identity should be linked to user") + + // Verify providers in app metadata + providers, ok := updatedUser.AppMetaData["providers"].([]interface{}) + ts.True(ok, "providers should exist in app_metadata") + ts.Contains(providers, "apple", "providers should include apple") + ts.Contains(providers, "email", "providers should maintain existing email provider") +} + +func (ts *ExternalTestSuite) TestLinkIdentityWithIDToken_AppleAlreadyLinked() { + // Setup first user with Apple identity + firstUser, err := ts.createUser("user1", "user1@example.com", "First User", "", "") + ts.Require().NoError(err) + + // Create Apple identity for first user + appleIdentity, err := models.NewIdentity(firstUser, "apple", map[string]interface{}{ + "sub": "apple123", + "name": "Apple User", + }) + ts.Require().NoError(err) + ts.Require().NoError(ts.API.db.Create(appleIdentity)) + + // Setup second user who will attempt to link + secondUser, err := ts.createUser("user2", "user2@example.com", "Second User", "", "") + ts.Require().NoError(err) + + // Create session for second user + session, err := models.NewSession(secondUser.ID, nil) + ts.Require().NoError(err) + ts.Require().NoError(ts.API.db.Create(session)) + + // Generate access token for second user + token, _, err := ts.API.generateAccessToken(httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil), ts.API.db, secondUser, &session.ID, models.PasswordGrant) + ts.Require().NoError(err) + + // Create the link request with same Apple identity + reqData := map[string]interface{}{ + "id_token": ts.mockAppleIDToken(&provider.UserProvidedData{ + Emails: []provider.Email{{Email: "apple@example.com", Verified: true, Primary: true}}, + Metadata: &provider.Claims{ + Subject: "apple123", + Name: "Apple Test User", + }, + }), + "provider": "apple", + } + reqBody, err := json.Marshal(reqData) + ts.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/user/identities/link_token", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + ts.Require().Equal(http.StatusUnprocessableEntity, w.Code) + + var response map[string]interface{} + err = json.NewDecoder(w.Body).Decode(&response) + ts.Require().NoError(err) + ts.Equal("identity_already_exists", response["code"]) + ts.Equal("Identity is already linked to another user", response["msg"]) +} + +func (ts *ExternalTestSuite) TestLinkIdentityWithIDToken_NoAuth() { + reqData := map[string]interface{}{ + "id_token": "test_token", + "provider": "apple", + } + reqBody, err := json.Marshal(reqData) + ts.Require().NoError(err) + + req := httptest.NewRequest(http.MethodPost, "/user/identities/link_token", bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + ts.Require().Equal(http.StatusUnauthorized, w.Code) +} + +// Helper function to mock Apple ID token +func (ts *ExternalTestSuite) mockAppleIDToken(userData *provider.UserProvidedData) string { + // In a real implementation, you would create a proper JWT + // For testing purposes, we'll create a simple token that the mocked provider can understand + claims := map[string]interface{}{ + "sub": userData.Metadata.Subject, + "name": userData.Metadata.Name, + "email": userData.Emails[0].Email, + "iss": "https://appleid.apple.com", + "aud": ts.Config.External.Apple.ClientID[0], + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)) + tokenString, err := token.SignedString([]byte(ts.Config.JWT.Secret)) + ts.Require().NoError(err) + + return tokenString +} \ No newline at end of file diff --git a/internal/api/link-identity-token.go b/internal/api/link-identity-token.go new file mode 100644 index 0000000000..1dddd5ff2a --- /dev/null +++ b/internal/api/link-identity-token.go @@ -0,0 +1,144 @@ +package api + +import ( + + "net/http" + + "github.com/fatih/structs" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/security" + "github.com/supabase/auth/internal/storage" +) + +// LinkIdentityWithIDTokenParams are the parameters for linking an identity using an ID token +type LinkIdentityWithIDTokenParams struct { + IdToken string `json:"id_token"` + Provider string `json:"provider"` + AccessToken string `json:"access_token,omitempty"` + Nonce string `json:"nonce,omitempty"` + ClientID string `json:"client_id,omitempty"` + Issuer string `json:"issuer,omitempty"` + + security.GotrueRequest +} + +// LinkIdentityWithIDToken links a new identity to an existing user using an OIDC ID token +// LinkIdentityWithIDToken links a new identity to an existing user using an OIDC ID token +func (a *API) LinkIdentityWithIDToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + config := a.config + + user := getUser(ctx) + if user == nil { + return unprocessableEntityError(ErrorCodeUserNotFound, "Missing authenticated user") + } + + params := &IdTokenGrantParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + if params.IdToken == "" { + return badRequestError(ErrorCodeValidationFailed, "id_token is required") + } + + if params.Provider == "" && (params.ClientID == "" || params.Issuer == "") { + return badRequestError(ErrorCodeValidationFailed, "provider or client_id and issuer are required") + } + + oidcProvider, skipNonceCheck, providerType, acceptableClientIDs, err := params.getProvider(ctx, config, r) + if err != nil { + return err + } + + idToken, userData, err := provider.ParseIDToken(ctx, oidcProvider, nil, params.IdToken, provider.ParseIDTokenOptions{ + SkipAccessTokenCheck: params.AccessToken == "", + AccessToken: params.AccessToken, + }) + if err != nil { + return oauthError("invalid_request", "Bad ID token").WithInternalError(err) + } + + correctAudience := false + for _, clientID := range acceptableClientIDs { + if clientID == "" { + continue + } + + for _, aud := range idToken.Audience { + if aud == clientID { + correctAudience = true + break + } + } + + if correctAudience { + break + } + } + + if !correctAudience { + return oauthError("invalid_request", "Unacceptable audience in id_token") + } + + if !skipNonceCheck && params.Nonce != "" { + if params.Nonce != idToken.Nonce { + return oauthError("invalid_nonce", "Invalid nonce") + } + } + + err = db.Transaction(func(tx *storage.Connection) error { + // Check if identity already exists + identity, terr := models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType) + if terr != nil { + if !models.IsNotFoundError(terr) { + return internalServerError("Database error finding identity").WithInternalError(terr) + } + } + + if identity != nil { + if identity.UserID == user.ID { + return unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked to this user") + } + return unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked to another user") + } + + // Create new identity + identityData := structs.Map(userData.Metadata) + newIdentity, terr := models.NewIdentity(user, providerType, identityData) + if terr != nil { + return terr + } + + if terr := tx.Create(newIdentity); terr != nil { + return internalServerError("Error creating identity").WithInternalError(terr) + } + + // Update user metadata + if terr := user.UpdateUserMetaData(tx, identityData); terr != nil { + return internalServerError("Error updating user metadata").WithInternalError(terr) + } + + // Update app metadata providers + if terr := user.UpdateAppMetaDataProviders(tx); terr != nil { + return internalServerError("Error updating user providers").WithInternalError(terr) + } + + // Create audit log entry + if terr := models.NewAuditLogEntry(r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ + "provider": providerType, + }); terr != nil { + return terr + } + + return nil + }) + + if err != nil { + return err + } + + return sendJSON(w, http.StatusOK, user) +}