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: add identity linking methods #814

Merged
merged 6 commits into from
Dec 6, 2023
Merged
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
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 (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check working correctly? It's showing as info instead of error in the logs. Also should the message string be 400: Identity is already linked to another user instead of Identity is already linked to another user?

image

I'm also getting a 401 on /user:
image

@J0

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @chhuang,

Thanks for the detailed write up! Looking at this now

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chhuang this is because the error is being appended as a query parameter in the redirect url but the http status code returned is 3xx and not a 4xx or 5xx

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 @@ -1376,7 +1385,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 @@ -1562,6 +1571,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: {}
kangmingtay marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1625,7 +1728,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 @@ -1794,13 +1897,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 @@ -2057,11 +2154,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 @@ -2097,8 +2196,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}`)
kangmingtay marked this conversation as resolved.
Show resolved Hide resolved
}

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

private async _unenroll(params: MFAUnenrollParams): Promise<AuthMFAUnenrollResponse> {
Expand Down
7 changes: 5 additions & 2 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ function base64urlencode(str: string) {
}

export async function generatePKCEChallenge(verifier: string) {
const hasCryptoSupport = typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined' && typeof TextEncoder !== 'undefined';

const hasCryptoSupport =
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof TextEncoder !== 'undefined'

if (!hasCryptoSupport) {
console.warn(
'WebCrypto API is not supported. Code challenge method will default to use plain instead of sha256.'
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
Loading