diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7db4def..62cdb75cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## HEAD +### Added + +* Public and private APIs for oauth account visibility and removal - requires migration to record user email on oauth accounts (#253) + ## 1.19.0 ### Added diff --git a/app/data/account_store.go b/app/data/account_store.go index 8a6ab2845..fcb57ffba 100644 --- a/app/data/account_store.go +++ b/app/data/account_store.go @@ -16,7 +16,9 @@ type AccountStore interface { Find(id int) (*models.Account, error) FindByUsername(u string) (*models.Account, error) FindByOauthAccount(p string, pid string) (*models.Account, error) - AddOauthAccount(id int, p string, pid string, tok string) error + AddOauthAccount(id int, p string, pid string, email string, tok string) error + UpdateOauthAccount(id int, p string, email string) (bool, error) + DeleteOauthAccount(id int, p string) (bool, error) GetOauthAccounts(id int) ([]*models.OauthAccount, error) Archive(id int) (bool, error) Lock(id int) (bool, error) diff --git a/app/data/mock/account_store.go b/app/data/mock/account_store.go index 6d59c8da3..10c484bee 100644 --- a/app/data/mock/account_store.go +++ b/app/data/mock/account_store.go @@ -94,7 +94,7 @@ func (s *accountStore) Create(u string, p []byte) (*models.Account, error) { return dupAccount(acc), nil } -func (s *accountStore) AddOauthAccount(accountID int, provider string, providerID string, tok string) error { +func (s *accountStore) AddOauthAccount(accountID int, provider, providerID, email, tok string) error { p := provider + "|" + providerID if s.idByOauthID[p] != 0 { return Error{ErrNotUnique} @@ -107,6 +107,7 @@ func (s *accountStore) AddOauthAccount(accountID int, provider string, providerI now := time.Now() oauthAccount := &models.OauthAccount{ + Email: email, AccountID: accountID, Provider: provider, ProviderID: providerID, @@ -124,6 +125,32 @@ func (s *accountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return s.oauthAccountsByID[accountID], nil } +func (s *accountStore) UpdateOauthAccount(accountID int, provider, email string) (bool, error) { + oauthAccounts := s.oauthAccountsByID[accountID] + + for i, oauthAccount := range oauthAccounts { + if oauthAccount.Provider == provider { + s.oauthAccountsByID[accountID][i].Email = email + return true, nil + } + } + + return false, nil +} + +func (s *accountStore) DeleteOauthAccount(accountID int, provider string) (bool, error) { + oauthAccounts := s.oauthAccountsByID[accountID] + + for i, oauthAccount := range oauthAccounts { + if oauthAccount.Provider == provider { + s.oauthAccountsByID[accountID] = append(oauthAccounts[:i], oauthAccounts[i+1:]...) + return true, nil + } + } + + return false, nil +} + func (s *accountStore) Archive(id int) (bool, error) { account := s.accountsByID[id] if account == nil { diff --git a/app/data/mysql/account_store.go b/app/data/mysql/account_store.go index 730254d3d..c7407e64e 100644 --- a/app/data/mysql/account_store.go +++ b/app/data/mysql/account_store.go @@ -76,13 +76,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, @@ -99,6 +100,24 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UDPATE oauth_accounts SET email = ? WHERE account_id = ? AND provider = ?", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ?", id) if err != nil { diff --git a/app/data/mysql/migrations.go b/app/data/mysql/migrations.go index 2e562d46f..af6976898 100644 --- a/app/data/mysql/migrations.go +++ b/app/data/mysql/migrations.go @@ -15,6 +15,7 @@ func MigrateDB(db *sqlx.DB) error { createOauthAccounts, createAccountLastLoginAtField, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -61,6 +62,18 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN email VARCHAR(255) DEFAULT NULL; + `) + if mysqlError, ok := err.(*mysql.MySQLError); ok { + if mysqlError.Number == 1060 { // 1060 = Duplicate column name + err = nil + } + } + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD last_login_at DATETIME DEFAULT NULL diff --git a/app/data/postgres/account_store.go b/app/data/postgres/account_store.go index 99e195d27..06c5b3336 100644 --- a/app/data/postgres/account_store.go +++ b/app/data/postgres/account_store.go @@ -88,13 +88,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, @@ -111,6 +112,24 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UPDATE oauth_accounts SET email = $1 WHERE account_id = $2 AND provider = $3", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = $1 AND provider = $2", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = $1", id) if err != nil { diff --git a/app/data/postgres/migrations.go b/app/data/postgres/migrations.go index a45a1b606..34440fcc9 100644 --- a/app/data/postgres/migrations.go +++ b/app/data/postgres/migrations.go @@ -13,6 +13,7 @@ func MigrateDB(db *sqlx.DB) error { createAccountLastLoginAtField, caseInsensitiveUsername, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -56,6 +57,13 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN IF NOT EXISTS email VARCHAR(255) DEFAULT NULL; + `) + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD COLUMN IF NOT EXISTS last_login_at timestamptz DEFAULT NULL diff --git a/app/data/sqlite3/account_store.go b/app/data/sqlite3/account_store.go index 6d35b55ac..5549aea4a 100644 --- a/app/data/sqlite3/account_store.go +++ b/app/data/sqlite3/account_store.go @@ -76,13 +76,14 @@ func (db *AccountStore) Create(u string, p []byte) (*models.Account, error) { return account, nil } -func (db *AccountStore) AddOauthAccount(accountID int, provider string, providerID string, accessToken string) error { +func (db *AccountStore) AddOauthAccount(accountID int, provider, providerID, email, accessToken string) error { now := time.Now() _, err := sqlx.NamedExec(db, ` - INSERT INTO oauth_accounts (account_id, provider, provider_id, access_token, created_at, updated_at) - VALUES (:account_id, :provider, :provider_id, :access_token, :created_at, :updated_at) + INSERT INTO oauth_accounts (account_id, provider, provider_id, email, access_token, created_at, updated_at) + VALUES (:account_id, :provider, :provider_id, :email, :access_token, :created_at, :updated_at) `, map[string]interface{}{ + "email": email, "account_id": accountID, "provider": provider, "provider_id": providerID, @@ -99,6 +100,24 @@ func (db *AccountStore) GetOauthAccounts(accountID int) ([]*models.OauthAccount, return accounts, err } +func (db *AccountStore) UpdateOauthAccount(accountId int, provider, email string) (bool, error) { + result, err := db.Exec("UPDATE oauth_accounts SET email = ? WHERE account_id = ? AND provider = ?", email, accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + +func (db *AccountStore) DeleteOauthAccount(accountId int, provider string) (bool, error) { + result, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ? AND provider = ?", accountId, provider) + if err != nil { + return false, err + } + + return ok(result, err) +} + func (db *AccountStore) Archive(id int) (bool, error) { _, err := db.Exec("DELETE FROM oauth_accounts WHERE account_id = ?", id) if err != nil { diff --git a/app/data/sqlite3/migrations.go b/app/data/sqlite3/migrations.go index 174a2cbc8..71a900b39 100644 --- a/app/data/sqlite3/migrations.go +++ b/app/data/sqlite3/migrations.go @@ -20,6 +20,7 @@ func MigrateDB(db *sqlx.DB) error { createAccountLastLoginAtField, caseInsensitiveUsername, createAccountTOTPFields, + addOauthAccountEmail, } for _, m := range migrations { if err := m(db); err != nil { @@ -96,6 +97,16 @@ func createOauthAccounts(db *sqlx.DB) error { return err } +func addOauthAccountEmail(db *sqlx.DB) error { + _, err := db.Exec(` + ALTER TABLE oauth_accounts ADD COLUMN email VARCHAR(255) DEFAULT NULL; + `) + if isDuplicateError(err) { + return nil + } + return err +} + func createAccountLastLoginAtField(db *sqlx.DB) error { _, err := db.Exec(` ALTER TABLE accounts ADD last_login_at DATETIME diff --git a/app/data/testers/account_store_testers.go b/app/data/testers/account_store_testers.go index f18345fea..9b48d093b 100644 --- a/app/data/testers/account_store_testers.go +++ b/app/data/testers/account_store_testers.go @@ -141,7 +141,7 @@ func testArchive(t *testing.T, store data.AccountStore) { func testArchiveWithOauth(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(account.ID, "PROVIDER", "PROVIDERID", "token") + err = store.AddOauthAccount(account.ID, "PROVIDER", "PROVIDERID", "email", "token") require.NoError(t, err) ok, err := store.Archive(account.ID) @@ -261,7 +261,7 @@ func testAddOauthAccount(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) assert.NoError(t, err) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "email", "TOKEN") assert.NoError(t, err) found, err = store.GetOauthAccounts(account.ID) @@ -274,7 +274,7 @@ func testAddOauthAccount(t *testing.T, store data.AccountStore) { assert.NotEmpty(t, found[0].CreatedAt) assert.NotEmpty(t, found[0].UpdatedAt) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID2", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID2", "email", "TOKEN") if err == nil || !data.IsUniquenessError(err) { t.Errorf("expected uniqueness error, got %T %v", err, err) } @@ -290,7 +290,7 @@ func testFindByOauthAccount(t *testing.T, store data.AccountStore) { account, err := store.Create("authn@keratin.tech", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "TOKEN") + err = store.AddOauthAccount(account.ID, "OAUTHPROVIDER", "PROVIDERID", "email", "TOKEN") require.NoError(t, err) found, err = store.FindByOauthAccount("unknown", "PROVIDERID") diff --git a/app/models/account.go b/app/models/account.go index eaa31ea19..4f689377f 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "encoding/json" "time" ) @@ -13,10 +14,11 @@ type Account struct { RequireNewPassword bool `db:"require_new_password"` PasswordChangedAt time.Time `db:"password_changed_at"` TOTPSecret sql.NullString `db:"totp_secret"` - LastLoginAt *time.Time `db:"last_login_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - DeletedAt *time.Time `db:"deleted_at"` + OauthAccounts []*OauthAccount + LastLoginAt *time.Time `db:"last_login_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` } func (a Account) Archived() bool { @@ -30,3 +32,33 @@ func (a Account) TOTPEnabled() bool { } return false } + +func (a Account) MarshalJSON() ([]byte, error) { + formattedLastLogin := "" + if a.LastLoginAt != nil { + formattedLastLogin = a.LastLoginAt.Format(time.RFC3339) + } + + formattedPasswordChangedAt := "" + if !a.PasswordChangedAt.IsZero() { + formattedPasswordChangedAt = a.PasswordChangedAt.Format(time.RFC3339) + } + + return json.Marshal(struct { + ID int `json:"id"` + Username string `json:"username"` + OauthAccounts []*OauthAccount `json:"oauth_accounts"` + LastLoginAt string `json:"last_login_at"` + PasswordChangedAt string `json:"password_changed_at"` + Locked bool `json:"locked"` + Deleted bool `json:"deleted"` + }{ + ID: a.ID, + Username: a.Username, + OauthAccounts: a.OauthAccounts, + LastLoginAt: formattedLastLogin, + PasswordChangedAt: formattedPasswordChangedAt, + Locked: a.Locked, + Deleted: a.DeletedAt != nil, + }) +} diff --git a/app/models/oauth_account.go b/app/models/oauth_account.go index ecf48d56f..1c366c640 100644 --- a/app/models/oauth_account.go +++ b/app/models/oauth_account.go @@ -1,13 +1,29 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type OauthAccount struct { ID int AccountID int `db:"account_id"` Provider string ProviderID string `db:"provider_id"` + Email string `db:"email"` AccessToken string `db:"access_token"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +func (o OauthAccount) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Provider string `json:"provider"` + ProviderID string `json:"provider_account_id"` + Email string `json:"email"` + }{ + Provider: o.Provider, + ProviderID: o.ProviderID, + Email: o.Email, + }) +} diff --git a/app/services/account_getter.go b/app/services/account_getter.go index 0d502a085..e5403ab77 100644 --- a/app/services/account_getter.go +++ b/app/services/account_getter.go @@ -15,5 +15,11 @@ func AccountGetter(store data.AccountStore, accountID int) (*models.Account, err return nil, FieldErrors{{"account", ErrNotFound}} } + oauthAccounts, err := store.GetOauthAccounts(accountID) + if err != nil { + return nil, errors.Wrap(err, "GetOauthAccounts") + } + + account.OauthAccounts = oauthAccounts return account, nil } diff --git a/app/services/account_getter_test.go b/app/services/account_getter_test.go new file mode 100644 index 000000000..5938e0507 --- /dev/null +++ b/app/services/account_getter_test.go @@ -0,0 +1,62 @@ +package services_test + +import ( + "sort" + "testing" + + "github.com/keratin/authn-server/app/data/mock" + "github.com/keratin/authn-server/app/services" + "github.com/stretchr/testify/require" +) + +func TestAccountGetter(t *testing.T) { + + t.Run("get non existing account", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := services.AccountGetter(accountStore, 9999) + + require.NotNil(t, err) + require.Nil(t, account) + }) + + t.Run("returns empty map when no oauth accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + acc, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + account, err := services.AccountGetter(accountStore, acc.ID) + require.NoError(t, err) + + require.Equal(t, 0, len(account.OauthAccounts)) + }) + + t.Run("returns oauth accounts for different providers", func(t *testing.T) { + accountStore := mock.NewAccountStore() + acc, err := accountStore.Create("user@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(acc.ID, "test", "ID1", "email1", "TOKEN1") + require.NoError(t, err) + + err = accountStore.AddOauthAccount(acc.ID, "trial", "ID2", "email2", "TOKEN2") + require.NoError(t, err) + + account, err := services.AccountGetter(accountStore, acc.ID) + require.NoError(t, err) + + oAccounts := account.OauthAccounts + + sort.Slice(oAccounts, func(i, j int) bool { + return oAccounts[i].ProviderID < oAccounts[j].ProviderID + }) + + require.Equal(t, 2, len(oAccounts)) + require.Equal(t, "test", oAccounts[0].Provider) + require.Equal(t, "ID1", oAccounts[0].ProviderID) + require.Equal(t, "email1", oAccounts[0].Email) + + require.Equal(t, "trial", oAccounts[1].Provider) + require.Equal(t, "ID2", oAccounts[1].ProviderID) + require.Equal(t, "email2", oAccounts[1].Email) + }) +} diff --git a/app/services/identity_reconciler.go b/app/services/identity_reconciler.go index e38684933..8aa457a69 100644 --- a/app/services/identity_reconciler.go +++ b/app/services/identity_reconciler.go @@ -38,12 +38,17 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide return nil, errors.New("account locked") } + err = updateUserInfo(accountStore, linkedAccount.ID, providerName, providerUser) + if err != nil { + return nil, errors.Wrap(err, "updateUserInfo") + } + return linkedAccount, nil } // 2. attempt linking to existing account if linkableAccountID != 0 { - err = accountStore.AddOauthAccount(linkableAccountID, providerName, providerUser.ID, providerToken.AccessToken) + err = accountStore.AddOauthAccount(linkableAccountID, providerName, providerUser.ID, providerUser.Email, providerToken.AccessToken) if err != nil { if data.IsUniquenessError(err) { return nil, errors.New("session conflict") @@ -68,7 +73,7 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide if err != nil { return nil, errors.Wrap(err, "AccountCreator") } - err = accountStore.AddOauthAccount(newAccount.ID, providerName, providerUser.ID, providerToken.AccessToken) + err = accountStore.AddOauthAccount(newAccount.ID, providerName, providerUser.ID, providerUser.Email, providerToken.AccessToken) if err != nil { // this should not happen since oauth details used to lookup account above // not sure how best to test but feels appropriate to return error if encountered @@ -76,3 +81,29 @@ func IdentityReconciler(accountStore data.AccountStore, cfg *app.Config, provide } return newAccount, nil } + +func updateUserInfo(accountStore data.AccountStore, accountID int, providerName string, providerUser *oauth.UserInfo) error { + oAccounts, err := accountStore.GetOauthAccounts(accountID) + if err != nil { + return errors.Wrap(err, "GetOauthAccounts") + } + + if len(oAccounts) == 0 { + return nil + } + + for _, oAccount := range oAccounts { + if providerName != oAccount.Provider && providerUser.ID != oAccount.ProviderID { + continue + } + + if oAccount.Email != providerUser.Email { + _, err = accountStore.UpdateOauthAccount(accountID, oAccount.Provider, providerUser.Email) + if err != nil { + return errors.Wrap(err, "UpdateOauthAccount") + } + } + } + + return nil +} diff --git a/app/services/identity_reconciler_test.go b/app/services/identity_reconciler_test.go index f003ad5de..0b35e9377 100644 --- a/app/services/identity_reconciler_test.go +++ b/app/services/identity_reconciler_test.go @@ -22,7 +22,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linked account", func(t *testing.T) { acct, err := store.Create("linked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "123", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "123", "email", "TOKEN") require.NoError(t, err) found, err := services.IdentityReconciler(store, cfg, "testProvider", &oauth.UserInfo{ID: "123", Email: "linked@test.com"}, &oauth2.Token{}, 0) @@ -35,7 +35,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linked account that is locked", func(t *testing.T) { acct, err := store.Create("linkedlocked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "234", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "234", "email", "TOKEN") require.NoError(t, err) _, err = store.Lock(acct.ID) require.NoError(t, err) @@ -59,7 +59,7 @@ func TestIdentityReconciler(t *testing.T) { t.Run("linkable account that is linked", func(t *testing.T) { acct, err := store.Create("linkablelinked@test.com", []byte("password")) require.NoError(t, err) - err = store.AddOauthAccount(acct.ID, "testProvider", "0", "TOKEN") + err = store.AddOauthAccount(acct.ID, "testProvider", "0", "email", "TOKEN") require.NoError(t, err) found, err := services.IdentityReconciler(store, cfg, "testProvider", &oauth.UserInfo{ID: "456", Email: "linkablelinked@test.com"}, &oauth2.Token{}, acct.ID) @@ -83,4 +83,46 @@ func TestIdentityReconciler(t *testing.T) { assert.Error(t, err) assert.Nil(t, found) }) + + t.Run("update missing email after oauth migration table", func(t *testing.T) { + provider := "testProvider" + providerAccountId := "666" + email := "update-missing-oauth-email@test.com" + + account, err := store.Create(email, []byte("password")) + require.NoError(t, err) + + err = store.AddOauthAccount(account.ID, provider, providerAccountId, "", "TOKEN") + require.NoError(t, err) + + found, err := services.IdentityReconciler(store, cfg, provider, &oauth.UserInfo{ID: providerAccountId, Email: email}, &oauth2.Token{}, 0) + assert.NoError(t, err) + assert.NotNil(t, found) + + oAccounts, err := store.GetOauthAccounts(account.ID) + assert.NoError(t, err) + assert.Equal(t, 1, len(oAccounts)) + assert.Equal(t, email, oAccounts[0].Email) + }) + + t.Run("update oauth email when is outdated", func(t *testing.T) { + provider := "testProvider" + providerAccountId := "777" + email := "update-outdate-oauth-email@test.com" + + account, err := store.Create(email, []byte("password")) + require.NoError(t, err) + + err = store.AddOauthAccount(account.ID, provider, providerAccountId, "email@email.com", "TOKEN") + require.NoError(t, err) + + found, err := services.IdentityReconciler(store, cfg, provider, &oauth.UserInfo{ID: providerAccountId, Email: email}, &oauth2.Token{}, 0) + assert.NoError(t, err) + assert.NotNil(t, found) + + oAccounts, err := store.GetOauthAccounts(account.ID) + assert.NoError(t, err) + assert.Equal(t, 1, len(oAccounts)) + assert.Equal(t, email, oAccounts[0].Email) + }) } diff --git a/app/services/identity_remover.go b/app/services/identity_remover.go new file mode 100644 index 000000000..ba42e14a1 --- /dev/null +++ b/app/services/identity_remover.go @@ -0,0 +1,25 @@ +package services + +import ( + "github.com/keratin/authn-server/app/data" +) + +func IdentityRemover(store data.AccountStore, accountId int, providers []string) error { + account, err := store.Find(accountId) + if err != nil { + return err + } + + if account == nil { + return FieldErrors{{"account", ErrNotFound}} + } + + for _, provider := range providers { + _, err = store.DeleteOauthAccount(accountId, provider) + if err != nil { + return err + } + } + + return nil +} diff --git a/app/services/identity_remover_test.go b/app/services/identity_remover_test.go new file mode 100644 index 000000000..465e6ba6f --- /dev/null +++ b/app/services/identity_remover_test.go @@ -0,0 +1,62 @@ +package services_test + +import ( + "testing" + + "github.com/keratin/authn-server/app/data/mock" + "github.com/keratin/authn-server/app/services" + "github.com/stretchr/testify/require" +) + +func TestIdentityRemover(t *testing.T) { + t.Run("delete non existing oauth accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = services.IdentityRemover(accountStore, account.ID, []string{"test"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + + require.Equal(t, len(oAccount), 0) + }) + + t.Run("delete account", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") + require.NoError(t, err) + + err = services.IdentityRemover(accountStore, account.ID, []string{"test"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + + require.Equal(t, len(oAccount), 0) + }) + + t.Run("delete multiple accounts", func(t *testing.T) { + accountStore := mock.NewAccountStore() + account, err := accountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "test", "TESTID", "email", "TOKEN") + require.NoError(t, err) + + err = accountStore.AddOauthAccount(account.ID, "trial", "TESTID", "email", "TOKEN") + require.NoError(t, err) + + err = services.IdentityRemover(accountStore, account.ID, []string{"test", "trial"}) + require.NoError(t, err) + + oAccount, err := accountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + + require.Equal(t, len(oAccount), 0) + }) +} diff --git a/docker-compose.yml b/docker-compose.yml index eaf841531..b5f3617d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: - "${REDIS_PORT:-8701}:6379" mysql: - platform: linux/x86_64 image: mysql:5.7 platform: linux/amd64 ports: diff --git a/docs/api.md b/docs/api.md index c07d92a10..a85884651 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,8 +10,10 @@ * [Username Availability](#username-availability) * [Lock Account](#lock-account) * [Unlock Account](#unlock-account) + * [Delete OAuth account by user id](#delete-oauth-account-by-user-id) * [Archive Account](#archive-account) * [Import Account](#import-account) + * Sessions * [Login](#login) * [Refresh Session](#refresh-session) @@ -26,6 +28,8 @@ * OAuth * [Begin OAuth](#begin-oauth) * [OAuth Return URL](#oauth-return) + * [Get OAuth accounts info](#get-oauth-accounts-info) + * [Delete OAuth account](#delete-oauth-account) * Multi-Factor Authentication (MFA) **BETA** * [New](#totp-new) * [Confirm](#totp-post) @@ -142,6 +146,13 @@ Visibility: Private "result": { "id": , "username": "...", + "oauth_accounts": [ + { + "provider": "google"|"apple", + "provider_account_id": "91293", + "email": "authn@keratin.com" + } + ], "last_login_at": "2006-01-02T15:04:05Z07:00", "password_changed_at": "2006-01-02T15:04:05Z07:00", "locked": false, @@ -295,6 +306,26 @@ Visibility: Private ] } +### Delete OAuth account by user id + +Visibility: Private + +`DELETE /accounts/:id/oauth/:name` + +| Params | Type | Notes | +| -------- | --------- | --------------- | +| `id` | integer | User account Id | +| `name` | string | Provider names | + +#### Success: + + 200 Ok + + {} + +#### Failure: + 404 Not Found + ### Import Account Visibility: Private @@ -616,7 +647,7 @@ Visibility: Public | Params | Type | Notes | | ------ | ---- | ----- | -| `providerName` | string | * google | +| `providerName` | string | google | | `redirect_uri` | URL | Return URL after OAuth. Must be in your application's domain. | Redirect a user to this URL when you want to authenticate them with OAuth, and include a `redirect_uri` where you want them to return when they're done. From here, a user will proceed to the OAuth provider and back to AuthN's [OAuth Return](#oauth-return) endpoint (as configured with the provider). @@ -658,6 +689,54 @@ If the OAuth process failed, the redirect will have `status=failed` appended to 303 See Other Location: (redirect URI with status=failed) +#### Get OAuth accounts info + +Visibility: Public + +`GET /oauth/accounts` + +Returns relevant oauth information for the current session. + +#### Success: + + 200 Ok + + { + "result": [ + { + "provider": "google"|"apple", + "provider_account_id": "91293", + "email": "authn@keratin.com" + } + ] + } + +#### Failure: + + 401 Unauthorized + +#### Delete OAuth account + +Visibility: Public + +`DELETE /oauth/:providerName` + +| Params | Type | Notes | +| -------------- | ------ | ------ | +| `providerName` | string | google | + +Delete an OAuth account from the current session. If the session was initiated via the OAuth flow, the endpoint will returns an error requesting user to reset password. + +#### Success: + + 200 Ok + + {} + +#### Failure: + + 401 Unauthorized + ### Multi-Factor Authentication (MFA) **NOTE** - AuthN MFA support is currently considered in beta. The API will not be considered stable until v2. diff --git a/lib/route/client.go b/lib/route/client.go index bbe0d0bed..884fc0034 100644 --- a/lib/route/client.go +++ b/lib/route/client.go @@ -28,7 +28,7 @@ const ( put = "PUT" options = "OPTIONS" - contentTypeJSON = "application/json" + contentTypeJSON = "application/json" contentTypeFormURLEncoded = "application/x-www-form-urlencoded" ) @@ -102,6 +102,16 @@ func (c *Client) Delete(path string) (*http.Response, error) { return c.do(delete, contentTypeFormURLEncoded, path, nil) } +// DeleteJSON issues a DELETE to the specified path like net/http's Delete, but with any modifications +// configured for the current client and accepting a JSON content. +func (c *Client) DeleteJSON(path string, content map[string]interface{}) (*http.Response, error) { + marshalled, err := json.Marshal(content) + if err != nil { + return nil, err + } + return c.do(delete, contentTypeJSON, path, bytes.NewReader(marshalled)) +} + // PostForm issues a POST to the specified path like net/http's PostForm, but with any modifications // configured for the current client. func (c *Client) PostForm(path string, form url.Values) (*http.Response, error) { @@ -145,7 +155,7 @@ func (c *Client) do(verb string, contentType string, path string, body io.Reader return nil, err } - if verb == post || verb == patch || verb == put { + if verb == post || verb == patch || verb == put || verb == delete { req.Header.Add("Content-Type", contentType) } diff --git a/server/handlers/delete_account_oauth.go b/server/handlers/delete_account_oauth.go new file mode 100644 index 000000000..ddec90174 --- /dev/null +++ b/server/handlers/delete_account_oauth.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" +) + +func DeleteAccountOauth(app *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + isNotFoundErr := func(err error) (string, bool) { + var fieldErr services.FieldErrors + + if errors.As(err, &fieldErr) { + for _, err := range fieldErr { + if err.Message == services.ErrNotFound { + return err.Field, true + } + } + } + + return "", false + } + + provider := mux.Vars(r)["name"] + accountID, err := strconv.Atoi(mux.Vars(r)["id"]) + if err != nil { + WriteNotFound(w, "account") + return + } + + err = services.IdentityRemover(app.AccountStore, accountID, []string{provider}) + if err != nil { + app.Logger.WithError(err).Error("IdentityRemover") + + if resource, ok := isNotFoundErr(err); ok { + WriteNotFound(w, resource) + return + } + + WriteErrors(w, err) + return + } + + w.WriteHeader(http.StatusOK) + } +} diff --git a/server/handlers/delete_account_oauth_test.go b/server/handlers/delete_account_oauth_test.go new file mode 100644 index 000000000..c59bbab67 --- /dev/null +++ b/server/handlers/delete_account_oauth_test.go @@ -0,0 +1,46 @@ +package handlers_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" + "github.com/stretchr/testify/require" +) + +func TestDeleteAccountOauth(t *testing.T) { + app := test.App() + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).Authenticated(app.Config.AuthUsername, app.Config.AuthPassword) + + http.DefaultClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("success", func(t *testing.T) { + account, err := app.AccountStore.Create("deleted-social-account@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") + require.NoError(t, err) + + url := fmt.Sprintf("/accounts/%d/oauth/%s", account.ID, "test") + res, err := client.Delete(url) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) + + t.Run("user does not exist", func(t *testing.T) { + res, err := client.Delete("/accounts/9999/oauth/test") + require.NoError(t, err) + + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/server/handlers/delete_oauth.go b/server/handlers/delete_oauth.go new file mode 100644 index 000000000..b96738d4c --- /dev/null +++ b/server/handlers/delete_oauth.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" + "github.com/keratin/authn-server/server/sessions" +) + +func DeleteOauth(app *app.App, providerName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + accountID := sessions.GetAccountID(r) + if accountID == 0 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + err := services.IdentityRemover(app.AccountStore, accountID, []string{providerName}) + if err != nil { + app.Logger.WithError(err).Error("IdentityRemover") + WriteErrors(w, err) + return + } + + w.WriteHeader(http.StatusOK) + } +} diff --git a/server/handlers/delete_oauth_test.go b/server/handlers/delete_oauth_test.go new file mode 100644 index 000000000..3cf907f63 --- /dev/null +++ b/server/handlers/delete_oauth_test.go @@ -0,0 +1,57 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + oauthlib "github.com/keratin/authn-server/lib/oauth" + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" +) + +func TestDeleteOauthAccount(t *testing.T) { + providerServer := httptest.NewServer(test.ProviderApp()) + defer providerServer.Close() + + app := test.App() + app.OauthProviders["test"] = *oauthlib.NewTestProvider(providerServer) + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).Referred(&app.Config.ApplicationDomains[0]).WithCookie(&http.Cookie{ + Name: app.Config.OAuthCookieName, + Value: "", + }) + + http.DefaultClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("unauthorized", func(t *testing.T) { + res, err := client.Delete("/oauth/test") + require.NoError(t, err) + + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) + + t.Run("success", func(t *testing.T) { + account, err := app.AccountStore.Create("deleted@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "DELETEDID", "email", "TOKEN") + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + res, err := client.WithCookie(session).Delete("/oauth/test") + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) +} diff --git a/server/handlers/get_account.go b/server/handlers/get_account.go index 386f6ef65..a47c87786 100644 --- a/server/handlers/get_account.go +++ b/server/handlers/get_account.go @@ -3,7 +3,6 @@ package handlers import ( "net/http" "strconv" - "time" "github.com/gorilla/mux" "github.com/keratin/authn-server/app" @@ -28,23 +27,6 @@ func GetAccount(app *app.App) http.HandlerFunc { panic(err) } - formattedLastLogin := "" - if account.LastLoginAt != nil { - formattedLastLogin = account.LastLoginAt.Format(time.RFC3339) - } - - formattedPasswordChangedAt := "" - if !account.PasswordChangedAt.IsZero() { - formattedPasswordChangedAt = account.PasswordChangedAt.Format(time.RFC3339) - } - - WriteData(w, http.StatusOK, map[string]interface{}{ - "id": account.ID, - "username": account.Username, - "last_login_at": formattedLastLogin, - "password_changed_at": formattedPasswordChangedAt, - "locked": account.Locked, - "deleted": account.DeletedAt != nil, - }) + WriteData(w, http.StatusOK, account) } } diff --git a/server/handlers/get_account_test.go b/server/handlers/get_account_test.go index 4423ad8a0..737dafc4b 100644 --- a/server/handlers/get_account_test.go +++ b/server/handlers/get_account_test.go @@ -3,7 +3,9 @@ package handlers_test import ( "fmt" "net/http" + "sort" "testing" + "time" "github.com/keratin/authn-server/app/models" "github.com/keratin/authn-server/lib/route" @@ -35,32 +37,61 @@ func TestGetAccount(t *testing.T) { account, err := app.AccountStore.Create("unlocked@test.com", []byte("bar")) require.NoError(t, err) + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID1", "email", "TOKEN1") + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "trial", "ID2", "email", "TOKEN2") + require.NoError(t, err) + + oauthAccounts, err := app.AccountStore.GetOauthAccounts(account.ID) + require.NoError(t, err) + res, err := client.Get(fmt.Sprintf("/accounts/%v", account.ID)) require.NoError(t, err) assert.Equal(t, http.StatusOK, res.StatusCode) - assertGetAccountResponse(t, res, account) + + assertGetAccountResponse(t, res, account, oauthAccounts) }) } -func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Account) { +func assertGetAccountResponse(t *testing.T, res *http.Response, acc *models.Account, oAccs []*models.OauthAccount) { + oAccounts := []map[string]interface{}{} + // check that the response contains the expected json - assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) - responseData := struct { - ID int `json:"id"` - Username string `json:"username"` - LastLoginAt string `json:"last_login_at"` - PasswordChangedAt string `json:"password_changed_at"` - Locked bool `json:"locked"` - Deleted bool `json:"deleted_at"` - }{} + type response struct { + ID int `json:"id"` + Username string `json:"username"` + OauthAccounts []map[string]interface{} `json:"oauth_accounts"` + LastLoginAt string `json:"last_login_at"` + PasswordChangedAt string `json:"password_changed_at"` + Locked bool `json:"locked"` + Deleted bool `json:"deleted_at"` + } + + var responseData response err := test.ExtractResult(res, &responseData) assert.NoError(t, err) - assert.Equal(t, acc.Username, responseData.Username) - assert.Equal(t, acc.ID, responseData.ID) - // NOTE: acc.LastLoginAt is empty so the API returns an empty response - assert.Equal(t, "", responseData.LastLoginAt) - assert.Equal(t, acc.PasswordChangedAt.Format("2006-01-02T15:04:05Z07:00"), responseData.PasswordChangedAt) - assert.Equal(t, false, responseData.Locked) - assert.Equal(t, false, responseData.Deleted) + sort.Slice(oAccs, func(i, j int) bool { + return oAccs[i].Provider < oAccs[j].Provider + }) + + for _, oAcc := range oAccs { + oAccounts = append(oAccounts, map[string]interface{}{ + "provider": oAcc.Provider, + "provider_account_id": oAcc.ProviderID, + "email": oAcc.Email, + }) + } + + assert.Equal(t, []string{"application/json"}, res.Header["Content-Type"]) + assert.Equal(t, responseData, response{ + ID: acc.ID, + Username: acc.Username, + OauthAccounts: oAccounts, + LastLoginAt: "", + PasswordChangedAt: acc.PasswordChangedAt.Format(time.RFC3339), + Locked: false, + Deleted: false, + }) } diff --git a/server/handlers/get_oauth_accounts.go b/server/handlers/get_oauth_accounts.go new file mode 100644 index 000000000..31eead8e8 --- /dev/null +++ b/server/handlers/get_oauth_accounts.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "github.com/keratin/authn-server/app" + "github.com/keratin/authn-server/app/services" + "github.com/keratin/authn-server/server/sessions" +) + +func GetOauthAccounts(app *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + accountID := sessions.GetAccountID(r) + if accountID == 0 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + account, err := services.AccountGetter(app.AccountStore, accountID) + if err != nil { + WriteErrors(w, err) + return + } + + WriteData(w, http.StatusOK, account.OauthAccounts) + } +} diff --git a/server/handlers/get_oauth_accounts_test.go b/server/handlers/get_oauth_accounts_test.go new file mode 100644 index 000000000..689725a9b --- /dev/null +++ b/server/handlers/get_oauth_accounts_test.go @@ -0,0 +1,62 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/keratin/authn-server/lib/route" + "github.com/keratin/authn-server/server/test" + "github.com/stretchr/testify/require" +) + +func TestGetOauthInfo(t *testing.T) { + app := test.App() + + server := test.Server(app) + defer server.Close() + + client := route.NewClient(server.URL).Referred(&app.Config.ApplicationDomains[0]).WithCookie(&http.Cookie{ + Name: app.Config.OAuthCookieName, + Value: "", + }) + + t.Run("unauthorized", func(t *testing.T) { + res, err := client.Get("/oauth/accounts") + require.NoError(t, err) + + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + require.Equal(t, []byte{}, test.ReadBody(res)) + }) + + t.Run("success", func(t *testing.T) { + var expected struct { + Result []struct { + Email string `json:"email"` + Provider string `json:"provider"` + ProviderAccountID string `json:"provider_account_id"` + } + } + + account, err := app.AccountStore.Create("get-oauth-info@keratin.tech", []byte("password")) + require.NoError(t, err) + + err = app.AccountStore.AddOauthAccount(account.ID, "test", "ID", "email", "TOKEN") + require.NoError(t, err) + + session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) + + res, err := client.WithCookie(session).Get("/oauth/accounts") + require.NoError(t, err) + + require.Equal(t, http.StatusOK, res.StatusCode) + + err = json.Unmarshal(test.ReadBody(res), &expected) + require.NoError(t, err) + + require.Equal(t, len(expected.Result), 1) + require.Equal(t, expected.Result[0].Email, "email") + require.Equal(t, expected.Result[0].Provider, "test") + require.Equal(t, expected.Result[0].ProviderAccountID, "ID") + }) +} diff --git a/server/handlers/get_oauth_return_test.go b/server/handlers/get_oauth_return_test.go index 54abfd6fc..ba02154a1 100644 --- a/server/handlers/get_oauth_return_test.go +++ b/server/handlers/get_oauth_return_test.go @@ -73,7 +73,7 @@ func TestGetOauthReturn(t *testing.T) { t.Run("not connect new identity with current session that is already linked", func(t *testing.T) { account, err := app.AccountStore.Create("linked@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "PREVIOUSID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "PREVIOUSID", "email", "TOKEN") require.NoError(t, err) session := test.CreateSession(app.RefreshTokenStore, app.Config, account.ID) @@ -86,7 +86,7 @@ func TestGetOauthReturn(t *testing.T) { linkedAccount, err := app.AccountStore.Create("linked.account@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(linkedAccount.ID, "test", "LINKEDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(linkedAccount.ID, "test", "LINKEDID", "email", "TOKEN") require.NoError(t, err) account, err := app.AccountStore.Create("registered.account@keratin.tech", []byte("password")) @@ -102,7 +102,7 @@ func TestGetOauthReturn(t *testing.T) { t.Run("log in to existing identity", func(t *testing.T) { account, err := app.AccountStore.Create("registered@keratin.tech", []byte("password")) require.NoError(t, err) - err = app.AccountStore.AddOauthAccount(account.ID, "test", "REGISTEREDID", "TOKEN") + err = app.AccountStore.AddOauthAccount(account.ID, "test", "REGISTEREDID", "email", "TOKEN") require.NoError(t, err) // codes don't normally specify the id, but our test provider is set up to reflect the code diff --git a/server/private_routes.go b/server/private_routes.go index 9ab0c5573..f394b359b 100644 --- a/server/private_routes.go +++ b/server/private_routes.go @@ -71,6 +71,10 @@ func PrivateRoutes(app *app.App) []*route.HandledRoute { route.Delete("/accounts/{id:[0-9]+}"). SecuredWith(authentication). Handle(handlers.DeleteAccount(app)), + + route.Delete("/accounts/{id:[0-9]+}/oauth/{name}"). + SecuredWith(authentication). + Handle(handlers.DeleteAccountOauth(app)), ) if app.Actives != nil { diff --git a/server/public_routes.go b/server/public_routes.go index 67ccb564b..d8ef7cb5b 100644 --- a/server/public_routes.go +++ b/server/public_routes.go @@ -56,6 +56,10 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { route.Delete("/totp"). SecuredWith(originSecurity). Handle(handlers.DeleteTOTP(app)), + + route.Get("/oauth/accounts"). + SecuredWith(originSecurity). + Handle(handlers.GetOauthAccounts(app)), ) if app.Config.EnableSignup { @@ -103,6 +107,9 @@ func PublicRoutes(app *app.App) []*route.HandledRoute { returnRoute. SecuredWith(route.Unsecured()). Handle(handlers.GetOauthReturn(app, providerName)), + route.Delete("/oauth/"+providerName). + SecuredWith(originSecurity). + Handle(handlers.DeleteOauth(app, providerName)), ) }