Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: link identities by token #1885

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
179 changes: 179 additions & 0 deletions internal/api/external_apple_link_test.go
Original file line number Diff line number Diff line change
@@ -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", "[email protected]", "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: "[email protected]",
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", "[email protected]", "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", "[email protected]", "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: "[email protected]", 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
}
144 changes: 144 additions & 0 deletions internal/api/link-identity-token.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading