Skip to content

Commit

Permalink
feat: add support for social login (#942)
Browse files Browse the repository at this point in the history
  • Loading branch information
dphilipson committed Sep 18, 2024
1 parent 533a73e commit aa00dc7
Show file tree
Hide file tree
Showing 19 changed files with 678 additions and 53 deletions.
2 changes: 2 additions & 0 deletions account-kit/core/src/createConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const createConfig = (
ssr,
storage,
connectors,
enablePopupOauth,
...connectionConfig
} = params;

Expand Down Expand Up @@ -81,6 +82,7 @@ export const createConfig = (
iframeConfig,
rootOrgId,
rpId,
enablePopupOauth,
},
sessionConfig,
storage: storage?.(
Expand Down
10 changes: 10 additions & 0 deletions account-kit/core/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions account-kit/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlchemySignerParams["client"], AlchemySignerWebClient>,
Expand Down
1 change: 1 addition & 0 deletions account-kit/react/src/hooks/useAuthenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function useAuthenticate(
): UseAuthenticateResult {
const { queryClient } = useAlchemyAccountContext();
const signer = useSigner();

const {
mutate: authenticate,
mutateAsync: authenticateAsync,
Expand Down
90 changes: 80 additions & 10 deletions account-kit/signer/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +28,7 @@ import {
type AlchemySignerEvent,
type AlchemySignerEvents,
} from "./types.js";
import { assertNever } from "./utils/typeAssertions.js";

export interface BaseAlchemySignerParams<TClient extends BaseSignerClient> {
client: TClient;
Expand Down Expand Up @@ -138,10 +139,41 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
{ 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<OauthConfig>} the config which must be loaded before
* using popup-based OAuth
*/
preparePopupOauth = (): Promise<OauthConfig> => this.inner.initOauth();

/**
* Authenticate a user with either an email or a passkey and create a session for that user
*
Expand Down Expand Up @@ -170,11 +202,19 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
* @returns {Promise<User>} the user that was authenticated
*/
authenticate: (params: AuthParams) => Promise<User> = 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);
};

/**
Expand Down Expand Up @@ -519,17 +559,20 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
): Promise<User> => {
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,
});

Expand Down Expand Up @@ -557,9 +600,10 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
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;
Expand All @@ -568,7 +612,7 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>

private authenticateWithPasskey = async (
args: Extract<AuthParams, { type: "passkey" }>
) => {
): Promise<User> => {
let user: User;
const shouldCreateNew = async () => {
if ("email" in args) {
Expand Down Expand Up @@ -604,6 +648,32 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
return user;
};

private authenticateWithOauth = async (
args: Extract<AuthParams, { type: "oauth" }>
): Promise<User> => {
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<AuthParams, { type: "oauthReturn" }>): Promise<User> =>
this.inner.completeAuthWithBundle({
bundle,
orgId,
connectedEventName: "connectedOauth",
});

private registerListeners = () => {
this.sessionManager.on("connected", (session) => {
this.store.setState({
Expand Down
34 changes: 31 additions & 3 deletions account-kit/signer/src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -39,6 +42,7 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
protected turnkeyClient: TurnkeyClient;
protected rootOrg: string;
protected eventEmitter: EventEmitter<AlchemySignerClientEvents>;
protected oauthConfig: OauthConfig | undefined;

/**
* Create a new instance of the Alchemy Signer client
Expand All @@ -47,7 +51,6 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
*/
constructor(params: BaseSignerClientParams) {
const { stamper, connection, rootOrgId } = params;

this.rootOrg = rootOrgId ?? "24c1acf5-810f-41e0-a503-d5d13fa8e830";
this.eventEmitter = new EventEmitter<AlchemySignerClientEvents>();
this.connectionConfig = ConnectionConfigSchema.parse(connection);
Expand All @@ -57,6 +60,16 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
);
}

/**
* Asynchronously fetches and sets the OAuth configuration.
*
* @returns {Promise<OauthConfig>} A promise that resolves to the OAuth configuration
*/
public initOauth = async (): Promise<OauthConfig> => {
this.oauthConfig = await this.getOauthConfig();
return this.oauthConfig;
};

protected get user() {
return this._user;
}
Expand Down Expand Up @@ -92,11 +105,14 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
exportStamper: ExportWalletStamper;
exportAs: "SEED_PHRASE" | "PRIVATE_KEY";
}): Promise<boolean> {
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}`);
}
}

Expand All @@ -110,17 +126,28 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
params: Omit<EmailAuthParams, "targetPublicKey">
): Promise<{ orgId: string }>;

public abstract completeEmailAuth(params: {
public abstract completeAuthWithBundle(params: {
bundle: string;
orgId: string;
connectedEventName: keyof AlchemySignerClientEvents;
}): Promise<User>;

public abstract oauthWithRedirect(
args: Extract<OauthParams, { mode: "redirect" }>
): Promise<never>;

public abstract oauthWithPopup(
args: Extract<OauthParams, { mode: "popup" }>
): Promise<User>;

public abstract disconnect(): Promise<void>;

public abstract exportWallet(params: TExportWalletParams): Promise<boolean>;

public abstract lookupUserWithPasskey(user?: User): Promise<User>;

protected abstract getOauthConfig(): Promise<OauthConfig>;

protected abstract getWebAuthnAttestation(
options: CredentialCreationOptions,
userDetails?: { username: string }
Expand Down Expand Up @@ -310,6 +337,7 @@ export abstract class BaseSignerClient<TExportWalletParams = unknown> {
body: SignerBody<R>
): Promise<SignerResponse<R>> => {
const url = this.connectionConfig.rpcUrl ?? "https://api.g.alchemy.com";

const basePath = "/signer";

const headers = new Headers();
Expand Down
Loading

0 comments on commit aa00dc7

Please sign in to comment.