Skip to content

Commit 483e24b

Browse files
authored
feat: add sign in with ethereum to signInWithWeb3 (#1082)
## What kind of change does this PR introduce? This PR adds SIWE (Sign-In-With-Ethereum) support to auth-js, related to [this PR on /auth](supabase/auth#2069) ## What is the current behavior? Multiple providers & SIWS (Solana) supported ## What is the new behavior? Add SIWE (Ethereum) support. ## Additional context The types are inspired by the the [viem](https://viem.sh/) library, which has minimal & modern types, but they were simplified and copied over to the local code to remove dependency on it.
1 parent 420930e commit 483e24b

File tree

11 files changed

+695
-189
lines changed

11 files changed

+695
-189
lines changed

src/GoTrueClient.ts

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
_ssoResponse,
3333
} from './lib/fetch'
3434
import {
35+
deepClone,
3536
Deferred,
3637
getItemAsync,
3738
isBrowser,
@@ -110,9 +111,18 @@ import type {
110111
SolanaWeb3Credentials,
111112
SolanaWallet,
112113
Web3Credentials,
114+
EthereumWeb3Credentials,
115+
EthereumWallet,
113116
} from './lib/types'
114117
import { stringToUint8Array, bytesToBase64URL } from './lib/base64url'
115-
import { deepClone } from './lib/helpers'
118+
import {
119+
fromHex,
120+
getAddress,
121+
Hex,
122+
toHex,
123+
createSiweMessage,
124+
SiweMessage,
125+
} from './lib/web3/ethereum'
116126

117127
polyfillGlobalThis() // Make "globalThis" available
118128

@@ -648,7 +658,10 @@ export default class GoTrueClient {
648658

649659
/**
650660
* Signs in a user by verifying a message signed by the user's private key.
651-
* Only Solana supported at this time, using the Sign in with Solana standard.
661+
* Supports Ethereum (via Sign-In-With-Ethereum) & Solana (Sign-In-With-Solana) standards,
662+
* both of which derive from the EIP-4361 standard
663+
* With slight variation on Solana's side.
664+
* @reference https://eips.ethereum.org/EIPS/eip-4361
652665
*/
653666
async signInWithWeb3(credentials: Web3Credentials): Promise<
654667
| {
@@ -659,11 +672,153 @@ export default class GoTrueClient {
659672
> {
660673
const { chain } = credentials
661674

662-
if (chain === 'solana') {
663-
return await this.signInWithSolana(credentials)
675+
switch (chain) {
676+
case 'ethereum':
677+
return await this.signInWithEthereum(credentials)
678+
case 'solana':
679+
return await this.signInWithSolana(credentials)
680+
default:
681+
throw new Error(`@supabase/auth-js: Unsupported chain "${chain}"`)
682+
}
683+
}
684+
685+
private async signInWithEthereum(
686+
credentials: EthereumWeb3Credentials
687+
): Promise<
688+
| { data: { session: Session; user: User }; error: null }
689+
| { data: { session: null; user: null }; error: AuthError }
690+
> {
691+
// TODO: flatten type
692+
let message: string
693+
let signature: Hex
694+
695+
if ('message' in credentials) {
696+
message = credentials.message
697+
signature = credentials.signature
698+
} else {
699+
const { chain, wallet, statement, options } = credentials
700+
701+
let resolvedWallet: EthereumWallet
702+
703+
if (!isBrowser()) {
704+
if (typeof wallet !== 'object' || !options?.url) {
705+
throw new Error(
706+
'@supabase/auth-js: Both wallet and url must be specified in non-browser environments.'
707+
)
708+
}
709+
710+
resolvedWallet = wallet
711+
} else if (typeof wallet === 'object') {
712+
resolvedWallet = wallet
713+
} else {
714+
const windowAny = window as any
715+
716+
if (
717+
'ethereum' in windowAny &&
718+
typeof windowAny.ethereum === 'object' &&
719+
'request' in windowAny.ethereum &&
720+
typeof windowAny.ethereum.request === 'function'
721+
) {
722+
resolvedWallet = windowAny.ethereum
723+
} else {
724+
throw new Error(
725+
`@supabase/auth-js: No compatible Ethereum wallet interface on the window object (window.ethereum) detected. Make sure the user already has a wallet installed and connected for this app. Prefer passing the wallet interface object directly to signInWithWeb3({ chain: 'ethereum', wallet: resolvedUserWallet }) instead.`
726+
)
727+
}
728+
}
729+
730+
const url = new URL(options?.url ?? window.location.href)
731+
732+
const accounts = await resolvedWallet
733+
.request({
734+
method: 'eth_requestAccounts',
735+
})
736+
.then((accs) => accs as string[])
737+
.catch(() => {
738+
throw new Error(
739+
`@supabase/auth-js: Wallet method eth_requestAccounts is missing or invalid`
740+
)
741+
})
742+
743+
if (!accounts || accounts.length === 0) {
744+
throw new Error(
745+
`@supabase/auth-js: No accounts available. Please ensure the wallet is connected.`
746+
)
747+
}
748+
749+
const address = getAddress(accounts[0])
750+
751+
let chainId = options?.signInWithEthereum?.chainId
752+
if (!chainId) {
753+
const chainIdHex = await resolvedWallet.request({
754+
method: 'eth_chainId',
755+
})
756+
chainId = fromHex(chainIdHex as Hex)
757+
}
758+
759+
const siweMessage: SiweMessage = {
760+
domain: url.host,
761+
address: address,
762+
statement: statement,
763+
uri: url.href,
764+
version: '1',
765+
chainId: chainId,
766+
nonce: options?.signInWithEthereum?.nonce,
767+
issuedAt: options?.signInWithEthereum?.issuedAt ?? new Date(),
768+
expirationTime: options?.signInWithEthereum?.expirationTime,
769+
notBefore: options?.signInWithEthereum?.notBefore,
770+
requestId: options?.signInWithEthereum?.requestId,
771+
resources: options?.signInWithEthereum?.resources,
772+
}
773+
774+
message = createSiweMessage(siweMessage)
775+
776+
// Sign message
777+
signature = (await resolvedWallet.request({
778+
method: 'personal_sign',
779+
params: [toHex(message), address],
780+
})) as Hex
664781
}
665782

666-
throw new Error(`@supabase/auth-js: Unsupported chain "${chain}"`)
783+
try {
784+
const { data, error } = await _request(
785+
this.fetch,
786+
'POST',
787+
`${this.url}/token?grant_type=web3`,
788+
{
789+
headers: this.headers,
790+
body: {
791+
chain: 'ethereum',
792+
message,
793+
signature,
794+
...(credentials.options?.captchaToken
795+
? { gotrue_meta_security: { captcha_token: credentials.options?.captchaToken } }
796+
: null),
797+
},
798+
xform: _sessionResponse,
799+
}
800+
)
801+
if (error) {
802+
throw error
803+
}
804+
if (!data || !data.session || !data.user) {
805+
return {
806+
data: { user: null, session: null },
807+
error: new AuthInvalidTokenResponseError(),
808+
}
809+
}
810+
if (data.session) {
811+
await this._saveSession(data.session)
812+
await this._notifyAllSubscribers('SIGNED_IN', data.session)
813+
}
814+
return { data: { ...data }, error }
815+
} catch (error) {
816+
if (isAuthError(error)) {
817+
return { data: { user: null, session: null }, error }
818+
}
819+
820+
throw error
821+
}
667822
}
668823

669824
private async signInWithSolana(credentials: SolanaWeb3Credentials) {

src/lib/types.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { EIP1193Provider } from './web3/ethereum'
12
import { AuthError } from './errors'
23
import { Fetch } from './fetch'
3-
import type { SolanaSignInInput, SolanaSignInOutput } from './solana'
4+
import type { SolanaSignInInput, SolanaSignInOutput } from './web3/solana'
5+
import { EthereumSignInInput, Hex } from './web3/ethereum'
46

57
/** One of the providers supported by GoTrue. */
68
export type Provider =
@@ -673,7 +675,46 @@ export type SolanaWeb3Credentials =
673675
}
674676
}
675677

676-
export type Web3Credentials = SolanaWeb3Credentials
678+
export type EthereumWallet = EIP1193Provider
679+
680+
export type EthereumWeb3Credentials =
681+
| {
682+
chain: 'ethereum'
683+
684+
/** Wallet interface to use. If not specified will default to `window.solana`. */
685+
wallet?: EthereumWallet
686+
687+
/** Optional statement to include in the Sign in with Solana message. Must not include new line characters. Most wallets like Phantom **require specifying a statement!** */
688+
statement?: string
689+
690+
options?: {
691+
/** URL to use with the wallet interface. Some wallets do not allow signing a message for URLs different from the current page. */
692+
url?: string
693+
694+
/** Verification token received when the user completes the captcha on the site. */
695+
captchaToken?: string
696+
697+
signInWithEthereum?: Partial<
698+
Omit<EthereumSignInInput, 'version' | 'domain' | 'uri' | 'statement'>
699+
>
700+
}
701+
}
702+
| {
703+
chain: 'ethereum'
704+
705+
/** Sign in with Ethereum compatible message. Must include `Issued At`, `URI` and `Version`. */
706+
message: string
707+
708+
/** Ed25519 signature of the message. */
709+
signature: Hex
710+
711+
options?: {
712+
/** Verification token received when the user completes the captcha on the site. */
713+
captchaToken?: string
714+
}
715+
}
716+
717+
export type Web3Credentials = SolanaWeb3Credentials | EthereumWeb3Credentials
677718

678719
export type VerifyOtpParams = VerifyMobileOtpParams | VerifyEmailOtpParams | VerifyTokenHashParams
679720
export interface VerifyMobileOtpParams {

0 commit comments

Comments
 (0)