From 9b0a7f8bdfe3b764d4631cac67ec6eb27341ee07 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 28 Nov 2023 11:37:18 -0800 Subject: [PATCH 1/6] refactor: remove unnecessary code --- src/GoTrueClient.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 6337df87f..cd3537995 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -5,7 +5,6 @@ import { AuthImplicitGrantRedirectError, AuthPKCEGrantCodeExchangeError, AuthInvalidCredentialsError, - AuthRetryableFetchError, AuthSessionMissingError, AuthInvalidTokenResponseError, AuthUnknownError, @@ -1794,13 +1793,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() { From 6cb64fbc30ad113b1bdff31a99f1247a62dd19c3 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 28 Nov 2023 19:29:18 -0800 Subject: [PATCH 2/6] fix: add identity_id to UserIdentity --- src/lib/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/types.ts b/src/lib/types.ts index 9711a751c..07f080d7e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 From bd4779a4b1d736c4429cb9ea69ba8ce52fb2b719 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Tue, 28 Nov 2023 19:32:30 -0800 Subject: [PATCH 3/6] feat: add identity linking methods --- src/GoTrueClient.ts | 106 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index cd3537995..fc2ebe614 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -77,6 +77,7 @@ import type { ResendParams, AuthFlowType, LockFunc, + UserIdentity, } from './lib/types' polyfillGlobalThis() // Make "globalThis" available @@ -1375,7 +1376,7 @@ export default class GoTrueClient { expires_at: expiresAt, refresh_token, token_type, - user: data.user!!, + user: data.user, } // Remove tokens from URL @@ -1561,6 +1562,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 { + 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. @@ -1624,7 +1719,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, @@ -2050,11 +2145,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)}`] @@ -2090,8 +2187,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 { From 514635ba00ff566ecdfa59cea85e192aa2581fdb Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 6 Dec 2023 14:15:31 -0800 Subject: [PATCH 4/6] fix: don't remove session if identity linking fails --- src/GoTrueClient.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index fc2ebe614..4819f2fac 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -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() From e0541a6825cb4c94c7d74920a0ad4b731b648ec8 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 6 Dec 2023 14:15:44 -0800 Subject: [PATCH 5/6] chore: prettier changes --- src/lib/helpers.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 1469f96e6..16c3ebce6 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -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.' From 30f64f400a5058f01cf26a24b992f14c19f162f6 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 6 Dec 2023 14:17:39 -0800 Subject: [PATCH 6/6] update react example --- example/react/src/App.js | 125 +++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 12 deletions(-) diff --git a/example/react/src/App.js b/example/react/src/App.js index 8ac42a463..6d27cd563 100644 --- a/example/react/src/App.js +++ b/example/react/src/App.js @@ -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' @@ -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(() => { @@ -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) @@ -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) } @@ -104,6 +107,91 @@ function App() { } } } + + const showIdentities = () => { + return session?.user?.identities?.map((identity) => { + return ( +
+
+ {identity.provider[0].toUpperCase() + identity.provider.slice(1)} +
+
{identity?.identity_data?.email}
+
+ +
+
+ ) + }) + } + + const showLinkingOptions = () => { + setShowModal(!showModal) + if (showModal && !modalRef.current?.open) { + modalRef.current?.showModal() + } else { + modalRef.current?.close() + } + } + + const linkingOptionsModal = () => { + return ( + +

Continue linking with:

+
+
+ + + +
+
+ + + +
+
+ +
+ ) + } + + 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 (
@@ -130,6 +218,19 @@ function App() { )}
+
+

Identities

+ {showIdentities()} + + {linkingOptionsModal()} +
+