Skip to content

Commit cff5bcb

Browse files
authored
feat: add signInWithWeb3 with solana (#1037)
Adds `signInWithWeb3` which allows [sign in with Solana](supabase/auth#1918). **Example usage** First [connect to the user's wallet](https://solana.com/developers/courses/intro-to-solana/interact-with-wallets) then: ```tsx const wallet = useWallet() <Button onClick={() => { await supabase.auth.signInWithWeb3({ chain: 'solana', wallet, statement: "Custom message" }) }}> Sign in with Solana </Button> ``` It also works without Wallet Standard, at least with Phantom, Solflare and Brave by reading the `window.solana` object or you can custom send a wallet object that satisfies the `signIn()` or `signMessage()` APIs. Additionally you can sign the message yourself and send it via the raw API: ```typescript await supabase.auth.signInWithWeb3({ chain: 'solana', message: 'supabase.com wants you to sign in with your Solana account:\n...', signature: signatureUint8Array, }) ```
1 parent b64fae2 commit cff5bcb

File tree

6 files changed

+343
-2
lines changed

6 files changed

+343
-2
lines changed

package-lock.json

+63
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@supabase/node-fetch": "^2.6.14"
4141
},
4242
"devDependencies": {
43+
"@solana/wallet-standard-features": "^1.3.0",
4344
"@types/faker": "^5.1.6",
4445
"@types/jest": "^28.1.6",
4546
"@types/jsonwebtoken": "^8.5.6",

src/GoTrueClient.ts

+212-1
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,11 @@ import type {
106106
JWK,
107107
JwtPayload,
108108
JwtHeader,
109+
SolanaWeb3Credentials,
110+
SolanaWallet,
111+
Web3Credentials,
109112
} from './lib/types'
110-
import { stringToUint8Array } from './lib/base64url'
113+
import { stringToUint8Array, bytesToBase64URL } from './lib/base64url'
111114

112115
polyfillGlobalThis() // Make "globalThis" available
113116

@@ -601,6 +604,214 @@ export default class GoTrueClient {
601604
})
602605
}
603606

607+
/**
608+
* Signs in a user by verifying a message signed by the user's private key.
609+
* Only Solana supported at this time, using the Sign in with Solana standard.
610+
*/
611+
async signInWithWeb3(credentials: Web3Credentials): Promise<
612+
| {
613+
data: { session: Session; user: User }
614+
error: null
615+
}
616+
| { data: { session: null; user: null }; error: AuthError }
617+
> {
618+
const { chain } = credentials
619+
620+
if (chain === 'solana') {
621+
return await this.signInWithSolana(credentials)
622+
}
623+
624+
throw new Error(`@supabase/auth-js: Unsupported chain "${chain}"`)
625+
}
626+
627+
private async signInWithSolana(credentials: SolanaWeb3Credentials) {
628+
let message: string
629+
let signature: Uint8Array
630+
631+
if ('message' in credentials) {
632+
message = credentials.message
633+
signature = credentials.signature
634+
} else {
635+
const { chain, wallet, statement, options } = credentials
636+
637+
let resolvedWallet: SolanaWallet
638+
639+
if (!isBrowser()) {
640+
if (typeof wallet !== 'object' || !options?.url) {
641+
throw new Error(
642+
'@supabase/auth-js: Both wallet and url must be specified in non-browser environments.'
643+
)
644+
}
645+
646+
resolvedWallet = wallet
647+
} else if (typeof wallet === 'object') {
648+
resolvedWallet = wallet
649+
} else {
650+
const windowAny = window as any
651+
652+
if (
653+
'solana' in windowAny &&
654+
typeof windowAny.solana === 'object' &&
655+
(('signIn' in windowAny.solana && typeof windowAny.solana.signIn === 'function') ||
656+
('signMessage' in windowAny.solana &&
657+
typeof windowAny.solana.signMessage === 'function'))
658+
) {
659+
resolvedWallet = windowAny.solana
660+
} else {
661+
throw new Error(
662+
`@supabase/auth-js: No compatible Solana wallet interface on the window object (window.solana) 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: 'solana', wallet: resolvedUserWallet }) instead.`
663+
)
664+
}
665+
}
666+
667+
const url = new URL(options?.url ?? window.location.href)
668+
669+
if ('signIn' in resolvedWallet && resolvedWallet.signIn) {
670+
const output = await resolvedWallet.signIn({
671+
issuedAt: new Date().toISOString(),
672+
673+
...options?.signInWithSolana,
674+
675+
// non-overridable properties
676+
version: '1',
677+
domain: url.host,
678+
uri: url.href,
679+
680+
...(statement ? { statement } : null),
681+
})
682+
683+
let outputToProcess: any
684+
685+
if (Array.isArray(output) && output[0] && typeof output[0] === 'object') {
686+
outputToProcess = output[0]
687+
} else if (
688+
output &&
689+
typeof output === 'object' &&
690+
'signedMessage' in output &&
691+
'signature' in output
692+
) {
693+
outputToProcess = output
694+
} else {
695+
throw new Error('@supabase/auth-js: Wallet method signIn() returned unrecognized value')
696+
}
697+
698+
if (
699+
'signedMessage' in outputToProcess &&
700+
'signature' in outputToProcess &&
701+
(typeof outputToProcess.signedMessage === 'string' ||
702+
outputToProcess.signedMessage instanceof Uint8Array) &&
703+
outputToProcess.signature instanceof Uint8Array
704+
) {
705+
message =
706+
typeof outputToProcess.signedMessage === 'string'
707+
? outputToProcess.signedMessage
708+
: new TextDecoder().decode(outputToProcess.signedMessage)
709+
signature = outputToProcess.signature
710+
} else {
711+
throw new Error(
712+
'@supabase/auth-js: Wallet method signIn() API returned object without signedMessage and signature fields'
713+
)
714+
}
715+
} else {
716+
if (
717+
!('signMessage' in resolvedWallet) ||
718+
typeof resolvedWallet.signMessage !== 'function' ||
719+
!('publicKey' in resolvedWallet) ||
720+
typeof resolvedWallet !== 'object' ||
721+
!resolvedWallet.publicKey ||
722+
!('toBase58' in resolvedWallet.publicKey) ||
723+
typeof resolvedWallet.publicKey.toBase58 !== 'function'
724+
) {
725+
throw new Error(
726+
'@supabase/auth-js: Wallet does not have a compatible signMessage() and publicKey.toBase58() API'
727+
)
728+
}
729+
730+
message = [
731+
`${url.host} wants you to sign in with your Solana account:`,
732+
resolvedWallet.publicKey.toBase58(),
733+
...(statement ? ['', statement, ''] : ['']),
734+
'Version: 1',
735+
`URI: ${url.href}`,
736+
`Issued At: ${options?.signInWithSolana?.issuedAt ?? new Date().toISOString()}`,
737+
...(options?.signInWithSolana?.notBefore
738+
? [`Not Before: ${options.signInWithSolana.notBefore}`]
739+
: []),
740+
...(options?.signInWithSolana?.expirationTime
741+
? [`Expiration Time: ${options.signInWithSolana.expirationTime}`]
742+
: []),
743+
...(options?.signInWithSolana?.chainId
744+
? [`Chain ID: ${options.signInWithSolana.chainId}`]
745+
: []),
746+
...(options?.signInWithSolana?.nonce ? [`Nonce: ${options.signInWithSolana.nonce}`] : []),
747+
...(options?.signInWithSolana?.requestId
748+
? [`Request ID: ${options.signInWithSolana.requestId}`]
749+
: []),
750+
...(options?.signInWithSolana?.resources?.length
751+
? [
752+
'Resources',
753+
...options.signInWithSolana.resources.map((resource) => `- ${resource}`),
754+
]
755+
: []),
756+
].join('\n')
757+
758+
const maybeSignature = await resolvedWallet.signMessage(
759+
new TextEncoder().encode(message),
760+
'utf8'
761+
)
762+
763+
if (!maybeSignature || !(maybeSignature instanceof Uint8Array)) {
764+
throw new Error(
765+
'@supabase/auth-js: Wallet signMessage() API returned an recognized value'
766+
)
767+
}
768+
769+
signature = maybeSignature
770+
}
771+
}
772+
773+
try {
774+
const { data, error } = await _request(
775+
this.fetch,
776+
'POST',
777+
`${this.url}/token?grant_type=web3`,
778+
{
779+
headers: this.headers,
780+
body: {
781+
chain: 'solana',
782+
message,
783+
signature: bytesToBase64URL(signature),
784+
785+
...(credentials.options?.captchaToken
786+
? { gotrue_meta_security: { captcha_token: credentials.options?.captchaToken } }
787+
: null),
788+
},
789+
xform: _sessionResponse,
790+
}
791+
)
792+
if (error) {
793+
throw error
794+
}
795+
if (!data || !data.session || !data.user) {
796+
return {
797+
data: { user: null, session: null },
798+
error: new AuthInvalidTokenResponseError(),
799+
}
800+
}
801+
if (data.session) {
802+
await this._saveSession(data.session)
803+
await this._notifyAllSubscribers('SIGNED_IN', data.session)
804+
}
805+
return { data: { ...data }, error }
806+
} catch (error) {
807+
if (isAuthError(error)) {
808+
return { data: { user: null, session: null }, error }
809+
}
810+
811+
throw error
812+
}
813+
}
814+
604815
private async _exchangeCodeForSession(authCode: string): Promise<
605816
| {
606817
data: { session: Session; user: User; redirectType: string | null }

src/lib/base64url.ts

+16
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,19 @@ export function stringToUint8Array(str: string): Uint8Array {
288288
stringToUTF8(str, (byte: number) => result.push(byte))
289289
return new Uint8Array(result)
290290
}
291+
292+
export function bytesToBase64URL(bytes: Uint8Array) {
293+
const result: string[] = []
294+
const state = { queue: 0, queuedBits: 0 }
295+
296+
const onChar = (char: string) => {
297+
result.push(char)
298+
}
299+
300+
bytes.forEach((byte) => byteToBase64URL(byte, state, onChar))
301+
302+
// always call with `null` after processing all bytes
303+
byteToBase64URL(null, state, onChar)
304+
305+
return result.join('')
306+
}

0 commit comments

Comments
 (0)