From aa00dc7d880f6d9cf9ae29e63941a9552faa9dd5 Mon Sep 17 00:00:00 2001 From: David Philipson Date: Tue, 17 Sep 2024 22:04:00 -0700 Subject: [PATCH] feat: add support for social login (#942) --- account-kit/core/src/createConfig.ts | 2 + account-kit/core/src/store/store.ts | 10 + account-kit/core/src/types.ts | 9 + .../react/src/hooks/useAuthenticate.ts | 1 + account-kit/signer/src/base.ts | 90 +++++- account-kit/signer/src/client/base.ts | 34 ++- account-kit/signer/src/client/index.ts | 256 +++++++++++++++++- account-kit/signer/src/client/types.ts | 26 ++ account-kit/signer/src/oauth.ts | 38 +++ account-kit/signer/src/session/manager.ts | 64 +++-- account-kit/signer/src/session/types.ts | 2 +- account-kit/signer/src/signer.ts | 16 +- .../signer/src/utils/typeAssertions.ts | 3 + package.json | 3 +- ...ailAuth.mdx => completeAuthWithBundle.mdx} | 11 +- .../AlchemySignerWebClient/oauthWithPopup.mdx | 49 ++++ .../oauthWithRedirect.mdx | 50 ++++ .../BaseAlchemySigner/preparePopupOauth.mdx | 47 ++++ .../classes/BaseSignerClient/initOauth.mdx | 20 ++ 19 files changed, 678 insertions(+), 53 deletions(-) create mode 100644 account-kit/signer/src/oauth.ts create mode 100644 account-kit/signer/src/utils/typeAssertions.ts rename site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/{completeEmailAuth.mdx => completeAuthWithBundle.mdx} (66%) create mode 100644 site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithPopup.mdx create mode 100644 site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithRedirect.mdx create mode 100644 site/pages/reference/account-kit/signer/classes/BaseAlchemySigner/preparePopupOauth.mdx create mode 100644 site/pages/reference/account-kit/signer/classes/BaseSignerClient/initOauth.mdx diff --git a/account-kit/core/src/createConfig.ts b/account-kit/core/src/createConfig.ts index 0756525f4..a96645b50 100644 --- a/account-kit/core/src/createConfig.ts +++ b/account-kit/core/src/createConfig.ts @@ -45,6 +45,7 @@ export const createConfig = ( ssr, storage, connectors, + enablePopupOauth, ...connectionConfig } = params; @@ -81,6 +82,7 @@ export const createConfig = ( iframeConfig, rootOrgId, rpId, + enablePopupOauth, }, sessionConfig, storage: storage?.( diff --git a/account-kit/core/src/store/store.ts b/account-kit/core/src/store/store.ts index 668b19bb9..396612386 100644 --- a/account-kit/core/src/store/store.ts +++ b/account-kit/core/src/store/store.ts @@ -190,6 +190,16 @@ export const createSigner = (params: ClientStoreConfig) => { const search = new URLSearchParams(window.location.search); if (search.has("bundle")) { signer.authenticate({ type: "email", bundle: search.get("bundle")! }); + } else if (search.has("alchemy-bundle")) { + signer.authenticate({ + type: "oauthReturn", + bundle: search.get("alchemy-bundle")!, + orgId: search.get("alchemy-org-id")!, + }); + } + + if (client.enablePopupOauth) { + signer.preparePopupOauth(); } return signer; diff --git a/account-kit/core/src/types.ts b/account-kit/core/src/types.ts index 2efb82558..4e9001ef7 100644 --- a/account-kit/core/src/types.ts +++ b/account-kit/core/src/types.ts @@ -103,6 +103,15 @@ export type CreateConfigProps = RpcConnectionConfig & { storage?: (config?: { sessionLength: number }) => Storage; connectors?: CreateConnectorFn[]; + + /** + * If set, calls `preparePopupOauth` immediately upon initializing the signer. + * If you intend to use popup-based OAuth login, you must either set this + * option to true or manually ensure that you call + * `signer.preparePopupOauth()` at some point before the user interaction that + * triggers the OAuth authentication flow. + */ + enablePopupOauth?: boolean; } & Omit< PartialBy< Exclude, diff --git a/account-kit/react/src/hooks/useAuthenticate.ts b/account-kit/react/src/hooks/useAuthenticate.ts index e6f09a509..6164f5524 100644 --- a/account-kit/react/src/hooks/useAuthenticate.ts +++ b/account-kit/react/src/hooks/useAuthenticate.ts @@ -47,6 +47,7 @@ export function useAuthenticate( ): UseAuthenticateResult { const { queryClient } = useAlchemyAccountContext(); const signer = useSigner(); + const { mutate: authenticate, mutateAsync: authenticateAsync, diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index d9ca28da5..6ba140b23 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -16,7 +16,7 @@ import type { Mutate, StoreApi } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import { createStore } from "zustand/vanilla"; import type { BaseSignerClient } from "./client/base"; -import type { User } from "./client/types"; +import type { OauthConfig, OauthParams, User } from "./client/types"; import { NotAuthenticatedError } from "./errors.js"; import { SessionManager, @@ -28,6 +28,7 @@ import { type AlchemySignerEvent, type AlchemySignerEvents, } from "./types.js"; +import { assertNever } from "./utils/typeAssertions.js"; export interface BaseAlchemySignerParams { client: TClient; @@ -138,10 +139,41 @@ export abstract class BaseAlchemySigner { fireImmediately: true } ); default: - throw new Error(`Uknown event type ${event}`); + throw new Error(`Unknown event type ${event}`); } }; + /** + * Prepares the config needed to use popup-based OAuth login. This must be + * called before calling `.authenticate` with params `{ type: "oauth", mode: + * "popup" }`, and is recommended to be called on page load. + * + * This method exists because browsers may prevent popups from opening unless + * triggered by user interaction, and so the OAuth config must already have + * been fetched at the time a user clicks a social login button. + * + * @example + * ```ts + * import { AlchemyWebSigner } from "@account-kit/signer"; + * + * const signer = new AlchemyWebSigner({ + * client: { + * connection: { + * rpcUrl: "/api/rpc", + * }, + * iframeConfig: { + * iframeContainerId: "alchemy-signer-iframe-container", + * }, + * }, + * }); + * + * await signer.preparePopupOauth(); + * ``` + * @returns {Promise} the config which must be loaded before + * using popup-based OAuth + */ + preparePopupOauth = (): Promise => this.inner.initOauth(); + /** * Authenticate a user with either an email or a passkey and create a session for that user * @@ -170,11 +202,19 @@ export abstract class BaseAlchemySigner * @returns {Promise} the user that was authenticated */ authenticate: (params: AuthParams) => Promise = async (params) => { - if (params.type === "email") { - return this.authenticateWithEmail(params); + const { type } = params; + switch (type) { + case "email": + return this.authenticateWithEmail(params); + case "passkey": + return this.authenticateWithPasskey(params); + case "oauth": + return this.authenticateWithOauth(params); + case "oauthReturn": + return this.handleOauthReturn(params); + default: + assertNever(type, `Unknown auth type: ${type}`); } - - return this.authenticateWithPasskey(params); }; /** @@ -519,17 +559,20 @@ export abstract class BaseAlchemySigner ): Promise => { if ("email" in params) { const existingUser = await this.getUser(params.email); + const expirationSeconds = Math.floor( + this.sessionManager.expirationTimeMs / 1000 + ); const { orgId } = existingUser ? await this.inner.initEmailAuth({ email: params.email, - expirationSeconds: this.sessionManager.expirationTimeMs, + expirationSeconds, redirectParams: params.redirectParams, }) : await this.inner.createAccount({ type: "email", email: params.email, - expirationSeconds: this.sessionManager.expirationTimeMs, + expirationSeconds, redirectParams: params.redirectParams, }); @@ -557,9 +600,10 @@ export abstract class BaseAlchemySigner throw new Error("Could not find email auth init session!"); } - const user = await this.inner.completeEmailAuth({ + const user = await this.inner.completeAuthWithBundle({ bundle: params.bundle, orgId: temporarySession.orgId, + connectedEventName: "connectedEmail", }); return user; @@ -568,7 +612,7 @@ export abstract class BaseAlchemySigner private authenticateWithPasskey = async ( args: Extract - ) => { + ): Promise => { let user: User; const shouldCreateNew = async () => { if ("email" in args) { @@ -604,6 +648,32 @@ export abstract class BaseAlchemySigner return user; }; + private authenticateWithOauth = async ( + args: Extract + ): Promise => { + const params: OauthParams = { + ...args, + expirationSeconds: Math.floor( + this.sessionManager.expirationTimeMs / 1000 + ), + }; + if (params.mode === "redirect") { + return this.inner.oauthWithRedirect(params); + } else { + return this.inner.oauthWithPopup(params); + } + }; + + private handleOauthReturn = ({ + bundle, + orgId, + }: Extract): Promise => + this.inner.completeAuthWithBundle({ + bundle, + orgId, + connectedEventName: "connectedOauth", + }); + private registerListeners = () => { this.sessionManager.on("connected", (session) => { this.store.setState({ diff --git a/account-kit/signer/src/client/base.ts b/account-kit/signer/src/client/base.ts index 262a7e9dc..aa4282ca5 100644 --- a/account-kit/signer/src/client/base.ts +++ b/account-kit/signer/src/client/base.ts @@ -10,12 +10,15 @@ import type { CreateAccountParams, EmailAuthParams, GetWebAuthnAttestationResult, + OauthConfig, + OauthParams, SignerBody, SignerResponse, SignerRoutes, SignupResponse, User, } from "./types.js"; +import { assertNever } from "../utils/typeAssertions.js"; export interface BaseSignerClientParams { stamper: TurnkeyClient["stamper"]; @@ -39,6 +42,7 @@ export abstract class BaseSignerClient { protected turnkeyClient: TurnkeyClient; protected rootOrg: string; protected eventEmitter: EventEmitter; + protected oauthConfig: OauthConfig | undefined; /** * Create a new instance of the Alchemy Signer client @@ -47,7 +51,6 @@ export abstract class BaseSignerClient { */ constructor(params: BaseSignerClientParams) { const { stamper, connection, rootOrgId } = params; - this.rootOrg = rootOrgId ?? "24c1acf5-810f-41e0-a503-d5d13fa8e830"; this.eventEmitter = new EventEmitter(); this.connectionConfig = ConnectionConfigSchema.parse(connection); @@ -57,6 +60,16 @@ export abstract class BaseSignerClient { ); } + /** + * Asynchronously fetches and sets the OAuth configuration. + * + * @returns {Promise} A promise that resolves to the OAuth configuration + */ + public initOauth = async (): Promise => { + this.oauthConfig = await this.getOauthConfig(); + return this.oauthConfig; + }; + protected get user() { return this._user; } @@ -92,11 +105,14 @@ export abstract class BaseSignerClient { exportStamper: ExportWalletStamper; exportAs: "SEED_PHRASE" | "PRIVATE_KEY"; }): Promise { - switch (params.exportAs) { + const { exportAs } = params; + switch (exportAs) { case "PRIVATE_KEY": return this.exportAsPrivateKey(params.exportStamper); case "SEED_PHRASE": return this.exportAsSeedPhrase(params.exportStamper); + default: + assertNever(exportAs, `Unknown export mode: ${exportAs}`); } } @@ -110,17 +126,28 @@ export abstract class BaseSignerClient { params: Omit ): Promise<{ orgId: string }>; - public abstract completeEmailAuth(params: { + public abstract completeAuthWithBundle(params: { bundle: string; orgId: string; + connectedEventName: keyof AlchemySignerClientEvents; }): Promise; + public abstract oauthWithRedirect( + args: Extract + ): Promise; + + public abstract oauthWithPopup( + args: Extract + ): Promise; + public abstract disconnect(): Promise; public abstract exportWallet(params: TExportWalletParams): Promise; public abstract lookupUserWithPasskey(user?: User): Promise; + protected abstract getOauthConfig(): Promise; + protected abstract getWebAuthnAttestation( options: CredentialCreationOptions, userDetails?: { username: string } @@ -310,6 +337,7 @@ export abstract class BaseSignerClient { body: SignerBody ): Promise> => { const url = this.connectionConfig.rpcUrl ?? "https://api.g.alchemy.com"; + const basePath = "/signer"; const headers = new Headers(); diff --git a/account-kit/signer/src/client/index.ts b/account-kit/signer/src/client/index.ts index 1e2c4b932..0c88e5df2 100644 --- a/account-kit/signer/src/client/index.ts +++ b/account-kit/signer/src/client/index.ts @@ -7,12 +7,19 @@ import { base64UrlEncode } from "../utils/base64UrlEncode.js"; import { generateRandomBuffer } from "../utils/generateRandomBuffer.js"; import { BaseSignerClient } from "./base.js"; import type { + AlchemySignerClientEvents, CreateAccountParams, CredentialCreationOptionOverrides, EmailAuthParams, ExportWalletParams, + OauthConfig, + OauthParams, User, } from "./types.js"; +import { getDefaultScopeAndClaims, getOauthNonce } from "../oauth.js"; +import type { AuthParams, OauthMode } from "../signer.js"; + +const CHECK_CLOSE_INTERVAL = 500; export const AlchemySignerClientParamsSchema = z.object({ connection: ConnectionConfigSchema, @@ -25,12 +32,27 @@ export const AlchemySignerClientParamsSchema = z.object({ .string() .optional() .default("24c1acf5-810f-41e0-a503-d5d13fa8e830"), + oauthCallbackUrl: z + .string() + .optional() + .default("https://signer.alchemy.com/callback"), + enablePopupOauth: z.boolean().optional().default(false), }); export type AlchemySignerClientParams = z.input< typeof AlchemySignerClientParamsSchema >; +type OauthState = { + authProviderId: string; + isCustomProvider?: boolean; + requestKey: string; + turnkeyPublicKey: string; + expirationSeconds?: number; + redirectUrl?: string; + openerOrigin?: string; +}; + /** * A lower level client used by the AlchemySigner used to communicate with * Alchemy's signer service. @@ -38,6 +60,7 @@ export type AlchemySignerClientParams = z.input< export class AlchemySignerWebClient extends BaseSignerClient { private iframeStamper: IframeStamper; private webauthnStamper: WebauthnStamper; + oauthCallbackUrl: string; iframeContainerId: string; /** @@ -64,7 +87,7 @@ export class AlchemySignerWebClient extends BaseSignerClient * @param {string} params.rootOrgId The root organization ID */ constructor(params: AlchemySignerClientParams) { - const { connection, iframeConfig, rpId, rootOrgId } = + const { connection, iframeConfig, rpId, rootOrgId, oauthCallbackUrl } = AlchemySignerClientParamsSchema.parse(params); const iframeStamper = new IframeStamper({ @@ -85,6 +108,8 @@ export class AlchemySignerWebClient extends BaseSignerClient this.webauthnStamper = new WebauthnStamper({ rpId: rpId ?? window.location.hostname, }); + + this.oauthCallbackUrl = oauthCallbackUrl; } /** @@ -109,7 +134,7 @@ export class AlchemySignerWebClient extends BaseSignerClient * @param {CreateAccountParams} params The parameters for creating an account, including the type (email or passkey) and additional details. * @returns {Promise} A promise that resolves with the response object containing the account creation result. */ - createAccount = async (params: CreateAccountParams) => { + public override createAccount = async (params: CreateAccountParams) => { this.eventEmitter.emit("authenticating"); if (params.type === "email") { const { email, expirationSeconds } = params; @@ -174,7 +199,7 @@ export class AlchemySignerWebClient extends BaseSignerClient * @param {Omit} params The parameters for email authentication, excluding the target public key * @returns {Promise} The response from the authentication request */ - public initEmailAuth = async ( + public override initEmailAuth = async ( params: Omit ) => { this.eventEmitter.emit("authenticating"); @@ -190,7 +215,7 @@ export class AlchemySignerWebClient extends BaseSignerClient }; /** - * Completes email auth for the user by injecting a credential bundle and retrieving the user information based on the provided organization ID. Emits events during the process. + * Completes auth for the user by injecting a credential bundle and retrieving the user information based on the provided organization ID. Emits events during the process. * * @example * ```ts @@ -205,19 +230,21 @@ export class AlchemySignerWebClient extends BaseSignerClient * }, * }); * - * const account = await client.completeEmailAuth({ orgId: "user-org-id", bundle: "bundle-from-email" }); + * const account = await client.completeAuthWithBundle({ orgId: "user-org-id", bundle: "bundle-from-email", connectedEventName: "connectedEmail" }); * ``` * * @param {{ bundle: string; orgId: string }} config The configuration object for the authentication function containing the credential bundle to inject and the organization id associated with the user * @returns {Promise} A promise that resolves to the authenticated user information */ - public completeEmailAuth = async ({ + public override completeAuthWithBundle = async ({ bundle, orgId, + connectedEventName, }: { bundle: string; orgId: string; - }) => { + connectedEventName: keyof AlchemySignerClientEvents; + }): Promise => { this.eventEmitter.emit("authenticating"); await this.initIframeStamper(); @@ -228,7 +255,7 @@ export class AlchemySignerWebClient extends BaseSignerClient } const user = await this.whoami(orgId); - this.eventEmitter.emit("connectedEmail", user, bundle); + this.eventEmitter.emit(connectedEventName, user, bundle); return user; }; @@ -255,7 +282,9 @@ export class AlchemySignerWebClient extends BaseSignerClient * @param {User} [user] An optional user object to authenticate * @returns {Promise} A promise that resolves to the authenticated user object */ - public lookupUserWithPasskey = async (user: User | undefined = undefined) => { + public override lookupUserWithPasskey = async ( + user: User | undefined = undefined + ) => { this.eventEmitter.emit("authenticating"); await this.initWebauthnStamper(user); if (user) { @@ -297,7 +326,7 @@ export class AlchemySignerWebClient extends BaseSignerClient * @param {string} [config.iframeElementId] Optional ID for the iframe element * @returns {Promise} A promise that resolves when the export process is complete */ - public exportWallet = async ({ + public override exportWallet = async ({ iframeContainerId, iframeElementId = "turnkey-export-iframe", }: ExportWalletParams) => { @@ -340,11 +369,187 @@ export class AlchemySignerWebClient extends BaseSignerClient * const account = await client.disconnect(); * ``` */ - public disconnect = async () => { + public override disconnect = async () => { this.user = undefined; this.iframeStamper.clear(); }; + /** + * Redirects the user to the OAuth provider URL based on the provided arguments. This function will always reject after 1 second if the redirection does not occur. + * + * @example + * ```ts + * import { AlchemySignerWebClient } from "@account-kit/signer"; + * + * const client = new AlchemySignerWebClient({ + * connection: { + * apiKey: "your-api-key", + * }, + * iframeConfig: { + * iframeContainerId: "signer-iframe-container", + * }, + * }); + * + * await client.oauthWithRedirect({ + * type: "oauth", + * authProviderId: "google", + * mode: "redirect", + * redirectUrl: "/", + * }); + * ``` + * + * @param {Extract} args The arguments required to obtain the OAuth provider URL + * @returns {Promise} A promise that will never resolve, only reject if the redirection fails + */ + public override oauthWithRedirect = async ( + args: Extract + ): Promise => { + const providerUrl = await this.getOauthProviderUrl(args); + window.location.href = providerUrl; + return new Promise((_, reject) => + setTimeout(() => reject("Failed to redirect to OAuth provider"), 1000) + ); + }; + + /** + * Initiates an OAuth authentication flow in a popup window and returns the authenticated user. + * + * @example + * ```ts + * import { AlchemySignerWebClient } from "@account-kit/signer"; + * + * const client = new AlchemySignerWebClient({ + * connection: { + * apiKey: "your-api-key", + * }, + * iframeConfig: { + * iframeContainerId: "signer-iframe-container", + * }, + * }); + * + * const user = await client.oauthWithPopup({ + * type: "oauth", + * authProviderId: "google", + * mode: "popup" + * }); + * ``` + * + * @param {Extract} args The authentication parameters specifying OAuth type and popup mode + * @returns {Promise} A promise that resolves to a `User` object containing the authenticated user information + */ + public override oauthWithPopup = async ( + args: Extract + ): Promise => { + const providerUrl = await this.getOauthProviderUrl(args); + const popup = window.open( + providerUrl, + "_blank", + "popup,width=500,height=600" + ); + return new Promise((resolve, reject) => { + const handleMessage = (event: MessageEvent) => { + if (!event.data) { + return; + } + const { alchemyBundle: bundle, alchemyOrgId: orgId } = event.data; + if (bundle && orgId) { + cleanup(); + this.completeAuthWithBundle({ + bundle, + orgId, + connectedEventName: "connectedOauth", + }).then(resolve, reject); + } + }; + + window.addEventListener("message", handleMessage); + + const checkCloseIntervalId = setInterval(() => { + if (popup?.closed) { + cleanup(); + reject(new Error("Oauth cancelled")); + } + }, CHECK_CLOSE_INTERVAL); + + const cleanup = () => { + window.removeEventListener("message", handleMessage); + clearInterval(checkCloseIntervalId); + }; + }); + }; + + private getOauthProviderUrl = async (args: OauthParams): Promise => { + const { + authProviderId, + isCustomProvider, + scope: providedScope, + claims: providedClaims, + mode, + redirectUrl, + expirationSeconds, + } = args; + const { codeChallenge, requestKey, authProviders } = + await this.getOauthConfigForMode(mode); + const authProvider = authProviders.find( + (provider) => + provider.id === authProviderId && + !!provider.isCustomProvider === !!isCustomProvider + ); + if (!authProvider) { + throw new Error(`No auth provider found with id ${authProviderId}`); + } + let scope: string; + let claims: string | undefined; + if (providedScope) { + scope = providedScope; + claims = providedClaims; + } else { + if (isCustomProvider) { + throw new Error("scope must be provided for a custom provider"); + } + const scopeAndClaims = getDefaultScopeAndClaims(authProviderId); + if (!scopeAndClaims) { + throw new Error( + `Default scope not known for provider ${authProviderId}` + ); + } + ({ scope, claims } = scopeAndClaims); + } + const { authEndpoint, clientId } = authProvider; + const turnkeyPublicKey = await this.initIframeStamper(); + const nonce = getOauthNonce(turnkeyPublicKey); + const stateObject: OauthState = { + authProviderId, + isCustomProvider, + requestKey, + turnkeyPublicKey, + expirationSeconds, + redirectUrl: + mode === "redirect" ? resolveRelativeUrl(redirectUrl) : undefined, + openerOrigin: mode === "popup" ? window.location.origin : undefined, + }; + const state = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(stateObject)) + ); + const authUrl = new URL(authEndpoint); + const params: Record = { + redirect_uri: this.oauthCallbackUrl, + response_type: "code", + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + prompt: "select_account", + client_id: clientId, + nonce, + }; + if (claims) { + params.claims = claims; + } + authUrl.search = new URLSearchParams(params).toString(); + return authUrl.toString(); + }; + private initIframeStamper = async () => { if (!this.iframeStamper.publicKey()) { await this.iframeStamper.init(); @@ -369,7 +574,7 @@ export class AlchemySignerWebClient extends BaseSignerClient } }; - protected getWebAuthnAttestation = async ( + protected override getWebAuthnAttestation = async ( options?: CredentialCreationOptionOverrides, userDetails: { username: string } = { username: this.user?.email ?? "anonymous", @@ -423,4 +628,31 @@ export class AlchemySignerWebClient extends BaseSignerClient return { challenge, authenticatorUserId, attestation }; }; + + protected override getOauthConfig = async (): Promise => { + const publicKey = await this.initIframeStamper(); + const nonce = getOauthNonce(publicKey); + return this.request("/v1/prepare-oauth", { nonce }); + }; + + private getOauthConfigForMode = async ( + mode: OauthMode + ): Promise => { + if (this.oauthConfig) { + return this.oauthConfig; + } else if (mode === "redirect") { + return this.initOauth(); + } else { + throw new Error( + "enablePopupOauth must be set in configuration or signer.preparePopupOauth must be called before using popup-based OAuth login" + ); + } + }; +} + +function resolveRelativeUrl(url: string): string { + // Funny trick. + const a = document.createElement("a"); + a.href = url; + return a.href; } diff --git a/account-kit/signer/src/client/types.ts b/account-kit/signer/src/client/types.ts index 3b37e92cd..f803d0e11 100644 --- a/account-kit/signer/src/client/types.ts +++ b/account-kit/signer/src/client/types.ts @@ -1,6 +1,7 @@ import type { Address } from "@aa-sdk/core"; import type { TSignedRequest, getWebAuthnAttestation } from "@turnkey/http"; import type { Hex } from "viem"; +import type { AuthParams } from "../signer"; export type CredentialCreationOptionOverrides = { publicKey?: Partial; @@ -46,12 +47,29 @@ export type EmailAuthParams = { redirectParams?: URLSearchParams; }; +export type OauthParams = Extract & { + expirationSeconds?: number; +}; + export type SignupResponse = { orgId: string; userId?: string; address?: Address; }; +export type OauthConfig = { + codeChallenge: string; + requestKey: string; + authProviders: AuthProviderConfig[]; +}; + +export type AuthProviderConfig = { + id: string; + isCustomProvider?: boolean; + clientId: string; + authEndpoint: string; +}; + export type SignerRoutes = SignerEndpoints[number]["Route"]; export type SignerBody = Extract< SignerEndpoints[number], @@ -106,6 +124,13 @@ export type SignerEndpoints = [ Response: { signature: Hex; }; + }, + { + Route: "/v1/prepare-oauth"; + Body: { + nonce: string; + }; + Response: OauthConfig; } ]; @@ -114,6 +139,7 @@ export type AlchemySignerClientEvents = { authenticating(): void; connectedEmail(user: User, bundle: string): void; connectedPasskey(user: User): void; + connectedOauth(user: User, bundle: string): void; disconnected(): void; }; diff --git a/account-kit/signer/src/oauth.ts b/account-kit/signer/src/oauth.ts new file mode 100644 index 000000000..4e1761707 --- /dev/null +++ b/account-kit/signer/src/oauth.ts @@ -0,0 +1,38 @@ +import { sha256 } from "viem"; + +/** + * Turnkey requires the nonce in the id token to be in this format. + * + * @param {string} turnkeyPublicKey key from a Turnkey iframe + * @returns {string} nonce to be used in OIDC + */ +export function getOauthNonce(turnkeyPublicKey: string): string { + return sha256(new TextEncoder().encode(turnkeyPublicKey)).slice(2); +} + +export type ScopeAndClaims = { + scope: string; + claims?: string; +}; + +const DEFAULT_SCOPE_AND_CLAIMS: Record = { + google: { scope: "openid email" }, + apple: { scope: "openid email" }, + facebook: { scope: "openid email" }, + twitch: { + scope: "openid user:read:email", + claims: JSON.stringify({ id_token: { email: null } }), + }, +}; + +/** + * Returns the default scope and claims when using a known auth provider + * + * @param {string} knownAuthProviderId id of a known auth provider, e.g. "google" + * @returns {ScopeAndClaims | undefined} default scope and claims + */ +export function getDefaultScopeAndClaims( + knownAuthProviderId: string +): ScopeAndClaims | undefined { + return DEFAULT_SCOPE_AND_CLAIMS[knownAuthProviderId]; +} diff --git a/account-kit/signer/src/session/manager.ts b/account-kit/signer/src/session/manager.ts index 2a6f678e6..67b15228a 100644 --- a/account-kit/signer/src/session/manager.ts +++ b/account-kit/signer/src/session/manager.ts @@ -9,6 +9,7 @@ import { createStore, type Mutate, type StoreApi } from "zustand/vanilla"; import type { BaseSignerClient } from "../client/base"; import type { User } from "../client/types"; import type { Session, SessionManagerEvents } from "./types"; +import { assertNever } from "../utils/typeAssertions.js"; export const DEFAULT_SESSION_MS = 15 * 60 * 1000; // 15 minutes @@ -82,11 +83,17 @@ export class SessionManager { } switch (existingSession.type) { - case "email": { + case "email": + case "oauth": { + const connectedEventName = + existingSession.type === "email" + ? "connectedEmail" + : "connectedOauth"; const result = await this.client - .completeEmailAuth({ + .completeAuthWithBundle({ bundle: existingSession.bundle, orgId: existingSession.user.orgId, + connectedEventName, }) .catch((e) => { console.warn("Failed to load user from session", e); @@ -108,7 +115,10 @@ export class SessionManager { return this.client.lookupUserWithPasskey(existingSession.user); } default: - throw new Error("Unknown session type"); + assertNever( + existingSession, + `Unknown session type: ${(existingSession as any).type}` + ); } }; @@ -168,7 +178,7 @@ export class SessionManager { private setSession = ( session: - | Omit, "expirationDateMs"> + | Omit, "expirationDateMs"> | Omit, "expirationDateMs"> ) => { this.store.setState({ @@ -211,21 +221,9 @@ export class SessionManager { this.client.on("disconnected", () => this.clearSession()); - this.client.on("connectedEmail", (user, bundle) => { - const existingSession = this.getSession(); - if ( - existingSession != null && - existingSession.type === "email" && - existingSession.user.userId === user.userId && - // if the bundle is different, then we've refreshed the session - // so we need to reset the session - existingSession.bundle === bundle - ) { - return; - } - - this.setSession({ type: "email", user, bundle }); - }); + this.client.on("connectedEmail", (user, bundle) => + this.setSessionWithUserAndBundle({ type: "email", user, bundle }) + ); this.client.on("connectedPasskey", (user) => { const existingSession = this.getSession(); @@ -240,10 +238,38 @@ export class SessionManager { this.setSession({ type: "passkey", user }); }); + this.client.on("connectedOauth", (user, bundle) => + this.setSessionWithUserAndBundle({ type: "oauth", user, bundle }) + ); + // sync local state if persisted state has changed from another tab window.addEventListener("focus", () => { this.store.persist.rehydrate(); this.initialize(); }); }; + + private setSessionWithUserAndBundle = ({ + type, + user, + bundle, + }: { + type: "email" | "oauth"; + user: User; + bundle: string; + }) => { + const existingSession = this.getSession(); + if ( + existingSession != null && + existingSession.type === type && + existingSession.user.userId === user.userId && + // if the bundle is different, then we've refreshed the session + // so we need to reset the session + existingSession.bundle === bundle + ) { + return; + } + + this.setSession({ type, user, bundle }); + }; } diff --git a/account-kit/signer/src/session/types.ts b/account-kit/signer/src/session/types.ts index 91be9b7b1..eda73c70f 100644 --- a/account-kit/signer/src/session/types.ts +++ b/account-kit/signer/src/session/types.ts @@ -2,7 +2,7 @@ import type { User } from "../client/types"; export type Session = | { - type: "email"; + type: "email" | "oauth"; bundle: string; expirationDateMs: number; user: User; diff --git a/account-kit/signer/src/signer.ts b/account-kit/signer/src/signer.ts index da6273579..6ab59cf6e 100644 --- a/account-kit/signer/src/signer.ts +++ b/account-kit/signer/src/signer.ts @@ -24,7 +24,21 @@ export type AuthParams = createNew: true; username: string; creationOpts?: CredentialCreationOptionOverrides; - }; + } + | ({ + type: "oauth"; + authProviderId: string; + isCustomProvider?: boolean; + scope?: string; + claims?: string; + } & RedirectConfig) + | { type: "oauthReturn"; bundle: string; orgId: string }; + +export type OauthMode = "redirect" | "popup"; + +export type RedirectConfig = + | { mode: "redirect"; redirectUrl: string } + | { mode: "popup"; redirectUrl?: never }; export const AlchemySignerParamsSchema = z .object({ diff --git a/account-kit/signer/src/utils/typeAssertions.ts b/account-kit/signer/src/utils/typeAssertions.ts new file mode 100644 index 000000000..b2364542e --- /dev/null +++ b/account-kit/signer/src/utils/typeAssertions.ts @@ -0,0 +1,3 @@ +export function assertNever(_: never, message: string): never { + throw new Error(message); +} diff --git a/package.json b/package.json index 974fc7d60..da88abe5a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,5 @@ "**/src/**/*.{tsx,jsx,ts,js}": [ "yarn lint:write" ] - }, - "version": "0.0.0" + } } diff --git a/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeEmailAuth.mdx b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeAuthWithBundle.mdx similarity index 66% rename from site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeEmailAuth.mdx rename to site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeAuthWithBundle.mdx index ece6b1bc0..d3c91020b 100644 --- a/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeEmailAuth.mdx +++ b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/completeAuthWithBundle.mdx @@ -1,13 +1,13 @@ --- # This file is autogenerated -title: completeEmailAuth -description: Overview of the completeEmailAuth method +title: completeAuthWithBundle +description: Overview of the completeAuthWithBundle method --- -# completeEmailAuth +# completeAuthWithBundle -Completes email auth for the user by injecting a credential bundle and retrieving the user information based on the provided organization ID. Emits events during the process. +Completes auth for the user by injecting a credential bundle and retrieving the user information based on the provided organization ID. Emits events during the process. ## Import @@ -29,9 +29,10 @@ const client = new AlchemySignerWebClient({ }, }); -const account = await client.completeEmailAuth({ +const account = await client.completeAuthWithBundle({ orgId: "user-org-id", bundle: "bundle-from-email", + connectedEventName: "connectedEmail", }); ``` diff --git a/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithPopup.mdx b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithPopup.mdx new file mode 100644 index 000000000..ace106379 --- /dev/null +++ b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithPopup.mdx @@ -0,0 +1,49 @@ +--- +# This file is autogenerated + +title: oauthWithPopup +description: Overview of the oauthWithPopup method +--- + +# oauthWithPopup + +Initiates an OAuth authentication flow in a popup window and returns the authenticated user. + +## Import + +```ts +import { AlchemySignerWebClient } from "@account-kit/signer"; +``` + +## Usage + +```ts +import { AlchemySignerWebClient } from "@account-kit/signer"; + +const client = new AlchemySignerWebClient({ + connection: { + apiKey: "your-api-key", + }, + iframeConfig: { + iframeContainerId: "signer-iframe-container", + }, +}); + +const user = await client.oauthWithPopup({ + type: "oauth", + authProviderId: "google", + mode: "popup", +}); +``` + +## Parameters + +### args + +`Extract` +The authentication parameters specifying OAuth type and popup mode + +## Returns + +`Promise` +A promise that resolves to a `User` object containing the authenticated user information diff --git a/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithRedirect.mdx b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithRedirect.mdx new file mode 100644 index 000000000..b381bf95d --- /dev/null +++ b/site/pages/reference/account-kit/signer/classes/AlchemySignerWebClient/oauthWithRedirect.mdx @@ -0,0 +1,50 @@ +--- +# This file is autogenerated + +title: oauthWithRedirect +description: Overview of the oauthWithRedirect method +--- + +# oauthWithRedirect + +Redirects the user to the OAuth provider URL based on the provided arguments. This function will always reject after 1 second if the redirection does not occur. + +## Import + +```ts +import { AlchemySignerWebClient } from "@account-kit/signer"; +``` + +## Usage + +```ts +import { AlchemySignerWebClient } from "@account-kit/signer"; + +const client = new AlchemySignerWebClient({ + connection: { + apiKey: "your-api-key", + }, + iframeConfig: { + iframeContainerId: "signer-iframe-container", + }, +}); + +await client.oauthWithRedirect({ + type: "oauth", + authProviderId: "google", + mode: "redirect", + redirectUrl: "/", +}); +``` + +## Parameters + +### args + +`Extract` +The arguments required to obtain the OAuth provider URL + +## Returns + +`Promise` +A promise that will never resolve, only reject if the redirection fails diff --git a/site/pages/reference/account-kit/signer/classes/BaseAlchemySigner/preparePopupOauth.mdx b/site/pages/reference/account-kit/signer/classes/BaseAlchemySigner/preparePopupOauth.mdx new file mode 100644 index 000000000..789c7baa8 --- /dev/null +++ b/site/pages/reference/account-kit/signer/classes/BaseAlchemySigner/preparePopupOauth.mdx @@ -0,0 +1,47 @@ +--- +# This file is autogenerated + +title: preparePopupOauth +description: Overview of the preparePopupOauth method +--- + +# preparePopupOauth + +Prepares the config needed to use popup-based OAuth login. This must be +called before calling `.authenticate` with params `{ type: "oauth", mode: +"popup" }`, and is recommended to be called on page load. + +This method exists because browsers may prevent popups from opening unless +triggered by user interaction, and so the OAuth config must already have +been fetched at the time a user clicks a social login button. + +## Import + +```ts +import { BaseAlchemySigner } from "@account-kit/signer"; +``` + +## Usage + +```ts +import { AlchemyWebSigner } from "@account-kit/signer"; + +const signer = new AlchemyWebSigner({ + client: { + connection: { + rpcUrl: "/api/rpc", + }, + iframeConfig: { + iframeContainerId: "alchemy-signer-iframe-container", + }, + }, +}); + +await signer.preparePopupOauth(); +``` + +## Returns + +`Promise` +the config which must be loaded before +using popup-based OAuth diff --git a/site/pages/reference/account-kit/signer/classes/BaseSignerClient/initOauth.mdx b/site/pages/reference/account-kit/signer/classes/BaseSignerClient/initOauth.mdx new file mode 100644 index 000000000..ff75ac28c --- /dev/null +++ b/site/pages/reference/account-kit/signer/classes/BaseSignerClient/initOauth.mdx @@ -0,0 +1,20 @@ +--- +# This file is autogenerated +title: initOauth +description: Overview of the initOauth method +--- + +# initOauth + +Asynchronously fetches and sets the OAuth configuration. + +## Import + +```ts +import { BaseSignerClient } from "@account-kit/signer"; +``` + +## Returns + +`Promise` +A promise that resolves to the OAuth configuration