diff --git a/package-lock.json b/package-lock.json index e9112664..5b7ea9d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@supabase/node-fetch": "^2.6.14" }, "devDependencies": { + "@solana/wallet-standard-features": "^1.3.0", "@types/faker": "^5.1.6", "@types/jest": "^28.1.6", "@types/jsonwebtoken": "^8.5.6", @@ -1285,6 +1286,20 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@solana/wallet-standard-features": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.3.0.tgz", + "integrity": "sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@supabase/node-fetch": { "version": "2.6.14", "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.14.tgz", @@ -1802,6 +1817,29 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@wallet-standard/base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", + "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.0.tgz", + "integrity": "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8401,6 +8439,16 @@ "@sinonjs/commons": "^1.7.0" } }, + "@solana/wallet-standard-features": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.3.0.tgz", + "integrity": "sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==", + "dev": true, + "requires": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + } + }, "@supabase/node-fetch": { "version": "2.6.14", "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.14.tgz", @@ -8826,6 +8874,21 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@wallet-standard/base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", + "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", + "dev": true + }, + "@wallet-standard/features": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.0.tgz", + "integrity": "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==", + "dev": true, + "requires": { + "@wallet-standard/base": "^1.1.0" + } + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", diff --git a/package.json b/package.json index bcfd23dc..daa946a2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@supabase/node-fetch": "^2.6.14" }, "devDependencies": { + "@solana/wallet-standard-features": "^1.3.0", "@types/faker": "^5.1.6", "@types/jest": "^28.1.6", "@types/jsonwebtoken": "^8.5.6", diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 517c7cb9..609a9020 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -106,8 +106,11 @@ import type { JWK, JwtPayload, JwtHeader, + SolanaWeb3Credentials, + SolanaWallet, + Web3Credentials, } from './lib/types' -import { stringToUint8Array } from './lib/base64url' +import { stringToUint8Array, bytesToBase64URL } from './lib/base64url' polyfillGlobalThis() // Make "globalThis" available @@ -601,6 +604,214 @@ export default class GoTrueClient { }) } + /** + * Signs in a user by verifying a message signed by the user's private key. + * Only Solana supported at this time, using the Sign in with Solana standard. + */ + async signInWithWeb3(credentials: Web3Credentials): Promise< + | { + data: { session: Session; user: User } + error: null + } + | { data: { session: null; user: null }; error: AuthError } + > { + const { chain } = credentials + + if (chain === 'solana') { + return await this.signInWithSolana(credentials) + } + + throw new Error(`@supabase/auth-js: Unsupported chain "${chain}"`) + } + + private async signInWithSolana(credentials: SolanaWeb3Credentials) { + let message: string + let signature: Uint8Array + + if ('message' in credentials) { + message = credentials.message + signature = credentials.signature + } else { + const { chain, wallet, statement, options } = credentials + + let resolvedWallet: SolanaWallet + + if (!isBrowser()) { + if (typeof wallet !== 'object' || !options?.url) { + throw new Error( + '@supabase/auth-js: Both wallet and url must be specified in non-browser environments.' + ) + } + + resolvedWallet = wallet + } else if (typeof wallet === 'object') { + resolvedWallet = wallet + } else { + const windowAny = window as any + + if ( + 'solana' in windowAny && + typeof windowAny.solana === 'object' && + (('signIn' in windowAny.solana && typeof windowAny.solana.signIn === 'function') || + ('signMessage' in windowAny.solana && + typeof windowAny.solana.signMessage === 'function')) + ) { + resolvedWallet = windowAny.solana + } else { + throw new Error( + `@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.` + ) + } + } + + const url = new URL(options?.url ?? window.location.href) + + if ('signIn' in resolvedWallet && resolvedWallet.signIn) { + const output = await resolvedWallet.signIn({ + issuedAt: new Date().toISOString(), + + ...options?.signInWithSolana, + + // non-overridable properties + version: '1', + domain: url.host, + uri: url.href, + + ...(statement ? { statement } : null), + }) + + let outputToProcess: any + + if (Array.isArray(output) && output[0] && typeof output[0] === 'object') { + outputToProcess = output[0] + } else if ( + output && + typeof output === 'object' && + 'signedMessage' in output && + 'signature' in output + ) { + outputToProcess = output + } else { + throw new Error('@supabase/auth-js: Wallet method signIn() returned unrecognized value') + } + + if ( + 'signedMessage' in outputToProcess && + 'signature' in outputToProcess && + (typeof outputToProcess.signedMessage === 'string' || + outputToProcess.signedMessage instanceof Uint8Array) && + outputToProcess.signature instanceof Uint8Array + ) { + message = + typeof outputToProcess.signedMessage === 'string' + ? outputToProcess.signedMessage + : new TextDecoder().decode(outputToProcess.signedMessage) + signature = outputToProcess.signature + } else { + throw new Error( + '@supabase/auth-js: Wallet method signIn() API returned object without signedMessage and signature fields' + ) + } + } else { + if ( + !('signMessage' in resolvedWallet) || + typeof resolvedWallet.signMessage !== 'function' || + !('publicKey' in resolvedWallet) || + typeof resolvedWallet !== 'object' || + !resolvedWallet.publicKey || + !('toBase58' in resolvedWallet.publicKey) || + typeof resolvedWallet.publicKey.toBase58 !== 'function' + ) { + throw new Error( + '@supabase/auth-js: Wallet does not have a compatible signMessage() and publicKey.toBase58() API' + ) + } + + message = [ + `${url.host} wants you to sign in with your Solana account:`, + resolvedWallet.publicKey.toBase58(), + ...(statement ? ['', statement, ''] : ['']), + 'Version: 1', + `URI: ${url.href}`, + `Issued At: ${options?.signInWithSolana?.issuedAt ?? new Date().toISOString()}`, + ...(options?.signInWithSolana?.notBefore + ? [`Not Before: ${options.signInWithSolana.notBefore}`] + : []), + ...(options?.signInWithSolana?.expirationTime + ? [`Expiration Time: ${options.signInWithSolana.expirationTime}`] + : []), + ...(options?.signInWithSolana?.chainId + ? [`Chain ID: ${options.signInWithSolana.chainId}`] + : []), + ...(options?.signInWithSolana?.nonce ? [`Nonce: ${options.signInWithSolana.nonce}`] : []), + ...(options?.signInWithSolana?.requestId + ? [`Request ID: ${options.signInWithSolana.requestId}`] + : []), + ...(options?.signInWithSolana?.resources?.length + ? [ + 'Resources', + ...options.signInWithSolana.resources.map((resource) => `- ${resource}`), + ] + : []), + ].join('\n') + + const maybeSignature = await resolvedWallet.signMessage( + new TextEncoder().encode(message), + 'utf8' + ) + + if (!maybeSignature || !(maybeSignature instanceof Uint8Array)) { + throw new Error( + '@supabase/auth-js: Wallet signMessage() API returned an recognized value' + ) + } + + signature = maybeSignature + } + } + + try { + const { data, error } = await _request( + this.fetch, + 'POST', + `${this.url}/token?grant_type=web3`, + { + headers: this.headers, + body: { + chain: 'solana', + message, + signature: bytesToBase64URL(signature), + + ...(credentials.options?.captchaToken + ? { gotrue_meta_security: { captcha_token: credentials.options?.captchaToken } } + : null), + }, + xform: _sessionResponse, + } + ) + if (error) { + throw error + } + if (!data || !data.session || !data.user) { + return { + data: { user: null, session: null }, + error: new AuthInvalidTokenResponseError(), + } + } + if (data.session) { + await this._saveSession(data.session) + await this._notifyAllSubscribers('SIGNED_IN', data.session) + } + return { data: { ...data }, error } + } catch (error) { + if (isAuthError(error)) { + return { data: { user: null, session: null }, error } + } + + throw error + } + } + private async _exchangeCodeForSession(authCode: string): Promise< | { data: { session: Session; user: User; redirectType: string | null } diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts index 3a5f2617..f7f6e0ac 100644 --- a/src/lib/base64url.ts +++ b/src/lib/base64url.ts @@ -288,3 +288,19 @@ export function stringToUint8Array(str: string): Uint8Array { stringToUTF8(str, (byte: number) => result.push(byte)) return new Uint8Array(result) } + +export function bytesToBase64URL(bytes: Uint8Array) { + const result: string[] = [] + const state = { queue: 0, queuedBits: 0 } + + const onChar = (char: string) => { + result.push(char) + } + + bytes.forEach((byte) => byteToBase64URL(byte, state, onChar)) + + // always call with `null` after processing all bytes + byteToBase64URL(null, state, onChar) + + return result.join('') +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 75a28f44..9e0c1f63 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,6 @@ import { AuthError } from './errors' import { Fetch } from './fetch' +import type { SolanaSignInInput, SolanaSignInOutput } from '@solana/wallet-standard-features' /** One of the providers supported by GoTrue. */ export type Provider = @@ -521,6 +522,7 @@ export type SignUpWithPasswordCredentials = channel?: 'sms' | 'whatsapp' } } + export type SignInWithPasswordCredentials = | { /** The user's email address. */ @@ -612,6 +614,54 @@ export type SignInWithIdTokenCredentials = { } } +export type SolanaWallet = { + signIn?: (...inputs: SolanaSignInInput[]) => Promise + publicKey?: { + toBase58: () => string + } | null + + signMessage?: (message: Uint8Array, encoding?: 'utf8' | string) => Promise | undefined +} + +export type SolanaWeb3Credentials = + | { + chain: 'solana' + + /** Wallet interface to use. If not specified will default to `window.solana`. */ + wallet?: SolanaWallet + + /** 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!** */ + statement?: string + + options?: { + /** URL to use with the wallet interface. Some wallets do not allow signing a message for URLs different from the current page. */ + url?: string + + /** Verification token received when the user completes the captcha on the site. */ + captchaToken?: string + + signInWithSolana?: Partial< + Omit + > + } + } + | { + chain: 'solana' + + /** Sign in with Solana compatible message. Must include `Issued At`, `URI` and `Version`. */ + message: string + + /** Ed25519 signature of the message. */ + signature: Uint8Array + + options?: { + /** Verification token received when the user completes the captcha on the site. */ + captchaToken?: string + } + } + +export type Web3Credentials = SolanaWeb3Credentials + export type VerifyOtpParams = VerifyMobileOtpParams | VerifyEmailOtpParams | VerifyTokenHashParams export interface VerifyMobileOtpParams { /** The user's phone number. */ diff --git a/src/lib/version.ts b/src/lib/version.ts index aee3a05c..9ccc1f93 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ -// Will be overwriten by .github/workflows/release.yml +// Generated by genversion. export const version = '0.0.0'