Skip to content

Commit

Permalink
feat: add identity linking methods (#814)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?
* Adds 3 new methods: `unlinkIdentity`, `linkIdentity`,
`getUserIdentities` to support the new identity linking endpoints
* All methods require the user to be authenticated 
* This PR should only be merged once the gotrue release with the new
identity linking endpoints are deployed
  • Loading branch information
kangmingtay committed Dec 6, 2023
1 parent abff667 commit 46d0f87
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 23 deletions.
125 changes: 113 additions & 12 deletions example/react/src/App.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { GoTrueClient } from '@supabase/gotrue-js'
import './tailwind.output.css'

Expand All @@ -21,16 +21,19 @@ function App() {
let [otp, setOtp] = useState('')
let [rememberMe, setRememberMe] = useState(false)

useEffect(() => {
async function session() {
const { data, error } = await auth.getSession()
if (error | !data) {
setSession('')
} else {
setSession(data.session)
}
const modalRef = useRef(null)
let [showModal, setShowModal] = useState(false)

async function getSession() {
const { data, error } = await auth.getSession()
if (error | !data) {
setSession('')
} else {
setSession(data.session)
}
session()
}
useEffect(() => {
getSession()
}, [])

useEffect(() => {
Expand All @@ -54,7 +57,7 @@ function App() {
let { error } = await auth.signInWithOAuth({
provider,
options: {
redirectTo: 'http://localhost:3000/welcome',
redirectTo: 'http://localhost:3001/welcome',
},
})
if (error) console.log('Error: ', error.message)
Expand Down Expand Up @@ -83,7 +86,7 @@ function App() {
let { error } = await auth.signUp({
email,
password,
options: { emailRedirectTo: 'http://localhost:3000/welcome' },
options: { emailRedirectTo: 'http://localhost:3001/welcome' },
})
if (error) console.log('Error: ', error.message)
}
Expand All @@ -104,6 +107,91 @@ function App() {
}
}
}

const showIdentities = () => {
return session?.user?.identities?.map((identity) => {
return (
<div
key={identity.identity_id}
className="flex flex-row p-2 my-2 bg-gray-200 max-h-100 rounded"
>
<div className="basis-1/4 p-2">
{identity.provider[0].toUpperCase() + identity.provider.slice(1)}
</div>
<div className="w-full basis-1/2 p-2">{identity?.identity_data?.email}</div>
<div>
<button
className="w-full basis-1/4 p-2 font-medium rounded-md text-white bg-gray-600 hover:bg-gray-500 focus:outline-none focus:border-gray-700 focus:shadow-outline-gray active:bg-gray-700 transition duration-150 ease-in-out"
onClick={() => handleUnlinkIdentity(identity)}
type="button"
>
Unlink
</button>
</div>
</div>
)
})
}

const showLinkingOptions = () => {
setShowModal(!showModal)
if (showModal && !modalRef.current?.open) {
modalRef.current?.showModal()
} else {
modalRef.current?.close()
}
}

const linkingOptionsModal = () => {
return (
<dialog className="bg-white shadow sm:rounded-lg" ref={modalRef}>
<p className="block text-sm font-medium leading-5 text-gray-700">Continue linking with:</p>
<div className="mt-6">
<div className="m-2">
<span className="block w-full rounded-md shadow-sm">
<button
onClick={() => auth.linkIdentity({ provider: 'github' })}
type="button"
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"
>
GitHub
</button>
</span>
</div>
<div className="m-2">
<span className="block w-full rounded-md shadow-sm">
<button
onClick={() => auth.linkIdentity({ provider: 'google' })}
type="button"
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"
>
Google
</button>
</span>
</div>
</div>
<button
className="text-sm font-medium leading-5 text-gray-700"
type="button"
onClick={showLinkingOptions}
>
Close
</button>
</dialog>
)
}

async function handleUnlinkIdentity(identity) {
let { error } = await auth.unlinkIdentity(identity)
if (error) {
alert(error.message)
} else {
alert(`successfully unlinked ${identity.provider} identity`)
const { data, error: refreshSessionError } = await auth.refreshSession()
if (refreshSessionError) alert(refreshSessionError.message)
setSession(data.session)
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
Expand All @@ -130,6 +218,19 @@ function App() {
)}
</div>

<div className="bg-white p-4 shadow sm:rounded-lg mb-10">
<p className="block text-sm font-medium leading-5 text-gray-700">Identities</p>
{showIdentities()}
<button
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-500 focus:outline-none focus:border-gray-700 focus:shadow-outline-gray active:bg-gray-700 transition duration-150 ease-in-out"
type="button"
onClick={showLinkingOptions}
>
Link Identity
</button>
{linkingOptionsModal()}
</div>

<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Expand Down
124 changes: 113 additions & 11 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
AuthImplicitGrantRedirectError,
AuthPKCEGrantCodeExchangeError,
AuthInvalidCredentialsError,
AuthRetryableFetchError,
AuthSessionMissingError,
AuthInvalidTokenResponseError,
AuthUnknownError,
Expand Down Expand Up @@ -78,6 +77,7 @@ import type {
ResendParams,
AuthFlowType,
LockFunc,
UserIdentity,
} from './lib/types'

polyfillGlobalThis() // Make "globalThis" available
Expand Down Expand Up @@ -286,6 +286,15 @@ export default class GoTrueClient {
if (error) {
this._debug('#_initialize()', 'error detecting session from URL', error)

// hacky workaround to keep the existing session if there's an error returned from identity linking
// TODO: once error codes are ready, we should match against it instead of the message
if (
error?.message === 'Identity is already linked' ||
error?.message === 'Identity is already linked to another user'
) {
return { error }
}

// failed login attempt via url,
// remove old session as in verifyOtp, signUp and signInWith*
await this._removeSession()
Expand Down Expand Up @@ -1387,7 +1396,7 @@ export default class GoTrueClient {
expires_at: expiresAt,
refresh_token,
token_type,
user: data.user!!,
user: data.user,
}

// Remove tokens from URL
Expand Down Expand Up @@ -1577,6 +1586,100 @@ export default class GoTrueClient {
}
}

/**
* Gets all the identities linked to a user.
*/
async getUserIdentities(): Promise<
| {
data: {
identities: UserIdentity[]
}
error: null
}
| { data: null; error: AuthError }
> {
try {
const { data, error } = await this.getUser()
if (error) throw error
return { data: { identities: data.user.identities ?? [] }, error: null }
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}
throw error
}
}
/**
* Links an oauth identity to an existing user.
* This method supports the PKCE flow.
*/
async linkIdentity(credentials: SignInWithOAuthCredentials): Promise<OAuthResponse> {
try {
const { data, error } = await this._useSession(async (result) => {
const { data, error } = result
if (error) throw error
const url: string = await this._getUrlForProvider(
`${this.url}/user/identities/authorize`,
credentials.provider,
{
redirectTo: credentials.options?.redirectTo,
scopes: credentials.options?.scopes,
queryParams: credentials.options?.queryParams,
skipBrowserRedirect: true,
}
)
return await _request(this.fetch, 'GET', url, {
headers: this.headers,
jwt: data.session?.access_token ?? undefined,
})
})
if (error) throw error
if (isBrowser() && !credentials.options?.skipBrowserRedirect) {
window.location.assign(data?.url)
}
return { data: { provider: credentials.provider, url: data?.url }, error: null }
} catch (error) {
if (isAuthError(error)) {
return { data: { provider: credentials.provider, url: null }, error }
}
throw error
}
}

/**
* Unlinks an identity from a user by deleting it. The user will no longer be able to sign in with that identity once it's unlinked.
*/
async unlinkIdentity(identity: UserIdentity): Promise<
| {
data: {}
error: null
}
| { data: null; error: AuthError }
> {
try {
return await this._useSession(async (result) => {
const { data, error } = result
if (error) {
throw error
}
return await _request(
this.fetch,
'DELETE',
`${this.url}/user/identities/${identity.identity_id}`,
{
headers: this.headers,
jwt: data.session?.access_token ?? undefined,
}
)
})
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}
throw error
}
}

/**
* Generates a new JWT.
* @param refreshToken A valid refresh token that was returned on login.
Expand Down Expand Up @@ -1640,7 +1743,7 @@ export default class GoTrueClient {
skipBrowserRedirect?: boolean
}
) {
const url: string = await this._getUrlForProvider(provider, {
const url: string = await this._getUrlForProvider(`${this.url}/authorize`, provider, {
redirectTo: options.redirectTo,
scopes: options.scopes,
queryParams: options.queryParams,
Expand Down Expand Up @@ -1814,13 +1917,7 @@ export default class GoTrueClient {
private async _saveSession(session: Session) {
this._debug('#_saveSession()', session)

await this._persistSession(session)
}

private _persistSession(currentSession: Session) {
this._debug('#_persistSession()', currentSession)

return setItemAsync(this.storage, this.storageKey, currentSession)
await setItemAsync(this.storage, this.storageKey, session)
}

private async _removeSession() {
Expand Down Expand Up @@ -2077,11 +2174,13 @@ export default class GoTrueClient {
* @param options.queryParams An object of key-value pairs containing query parameters granted to the OAuth application.
*/
private async _getUrlForProvider(
url: string,
provider: Provider,
options: {
redirectTo?: string
scopes?: string
queryParams?: { [key: string]: string }
skipBrowserRedirect?: boolean
}
) {
const urlParams: string[] = [`provider=${encodeURIComponent(provider)}`]
Expand Down Expand Up @@ -2117,8 +2216,11 @@ export default class GoTrueClient {
const query = new URLSearchParams(options.queryParams)
urlParams.push(query.toString())
}
if (options?.skipBrowserRedirect) {
urlParams.push(`skip_http_redirect=${options.skipBrowserRedirect}`)
}

return `${this.url}/authorize?${urlParams.join('&')}`
return `${url}?${urlParams.join('&')}`
}

private async _unenroll(params: MFAUnenrollParams): Promise<AuthMFAUnenrollResponse> {
Expand Down
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export interface UserIdentity {
identity_data?: {
[key: string]: any
}
identity_id: string
provider: string
created_at?: string
last_sign_in_at?: string
Expand Down

0 comments on commit 46d0f87

Please sign in to comment.