Skip to content

Instantly share code, notes, and snippets.

@xinha-sh
Last active June 27, 2025 17:21
Show Gist options
  • Select an option

  • Save xinha-sh/723fae6bad78a303bbaeeb2b41634c76 to your computer and use it in GitHub Desktop.

Select an option

Save xinha-sh/723fae6bad78a303bbaeeb2b41634c76 to your computer and use it in GitHub Desktop.
Expo auth implementation using SST Auth V2
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { type JSONWebKeySet, createLocalJWKSet, errors, jwtVerify } from "jose";
import {
InvalidAuthorizationCodeError,
InvalidRefreshTokenError,
InvalidSessionError,
} from "./error";
import { generatePKCE } from "./pkce";
import type { SubjectSchema } from "./session";
export interface WellKnown {
jwks_uri: string;
token_endpoint: string;
authorization_endpoint: string;
}
const jwksCache = new Map<string, ReturnType<typeof createLocalJWKSet>>();
const issuerCache = new Map<string, WellKnown>();
interface ResponseLike {
json(): Promise<unknown>;
ok: Response["ok"];
}
type FetchLike = (...args: any[]) => Promise<ResponseLike>;
export function createClient(input: {
clientID: string;
issuer?: string;
fetch?: FetchLike;
}) {
const issuer = input.issuer || process.env.OPENAUTH_ISSUER;
if (!issuer) throw new Error("No issuer");
const f = input.fetch ?? fetch;
async function getIssuer() {
const cached = issuerCache.get(issuer!);
if (cached) return cached;
const wellKnown = (await (f || fetch)(
`${issuer}/.well-known/oauth-authorization-server`,
).then((r) => r.json())) as WellKnown;
issuerCache.set(issuer!, wellKnown);
return wellKnown;
}
async function getJWKS() {
const wk = await getIssuer();
const cached = jwksCache.get(issuer!);
if (cached) return cached;
const keyset = (await (f || fetch)(wk.jwks_uri).then((r) =>
r.json(),
)) as JSONWebKeySet;
const result = createLocalJWKSet(keyset);
jwksCache.set(issuer!, result);
return result;
}
const result = {
authorize(
redirectURI: string,
response: "code" | "token",
opts?: {
provider?: string;
},
) {
const result = new URL(`${issuer}/authorize`);
if (opts?.provider) result.searchParams.set("provider", opts.provider);
result.searchParams.set("client_id", input.clientID);
result.searchParams.set("redirect_uri", redirectURI);
result.searchParams.set("response_type", response);
return result.toString();
},
async pkce(
redirectURI: string,
opts?: {
provider?: string;
},
) {
const result = new URL(`${issuer}/authorize`);
if (opts?.provider) result.searchParams.set("provider", opts.provider);
result.searchParams.set("client_id", input.clientID);
result.searchParams.set("redirect_uri", redirectURI);
result.searchParams.set("response_type", "code");
const pkce = await generatePKCE();
result.searchParams.set("code_challenge_method", "S256");
result.searchParams.set("code_challenge", pkce.challenge);
return [pkce.verifier, result.toString()];
},
async exchange(code: string, redirectURI: string, verifier?: string) {
const tokens = await f(`${issuer}/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code,
redirect_uri: redirectURI,
grant_type: "authorization_code",
client_id: input.clientID,
code_verifier: verifier || "",
}).toString(),
});
const json = (await tokens.json()) as any;
if (!tokens.ok) {
console.error(json);
throw new InvalidAuthorizationCodeError();
}
return {
access: json.access_token as string,
refresh: json.refresh_token as string,
};
},
async verify<T extends SubjectSchema>(
subjects: T,
token: string,
options?: {
refresh?: string;
issuer?: string;
audience?: string;
fetch?: typeof fetch;
},
): Promise<{
tokens?: {
access: string;
refresh: string;
};
subject: {
[type in keyof T]: {
type: type;
properties: StandardSchemaV1.InferOutput<T[type]>;
};
}[keyof T];
}> {
const jwks = await getJWKS();
try {
const result = await jwtVerify<{
mode: "access";
type: keyof T;
properties: StandardSchemaV1.InferInput<T[keyof T]>;
}>(token, jwks, {
issuer,
});
const validated = await subjects[result.payload.type][
"~standard"
].validate(result.payload.properties);
if (!validated.issues && result.payload.mode === "access")
return {
subject: {
type: result.payload.type,
properties: validated.value,
} as any,
};
} catch (e) {
if (e instanceof errors.JWTExpired && options?.refresh) {
const wk = await getIssuer();
const tokens = await f(wk.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: options.refresh,
}).toString(),
});
const json = (await tokens.json()) as any;
if (!tokens.ok) {
console.error(json);
throw new InvalidRefreshTokenError();
}
const verified = await result.verify(subjects, json.access_token, {
refresh: json.refresh_token,
issuer,
fetch: options?.fetch,
});
verified.tokens = {
access: json.access_token,
refresh: json.refresh_token,
};
return verified;
}
throw e;
}
throw new InvalidSessionError();
},
};
return result;
}
export class OauthError extends Error {
constructor(
public error:
| "invalid_request"
| "invalid_grant"
| "unauthorized_client"
| "access_denied"
| "unsupported_grant_type"
| "server_error"
| "temporarily_unavailable",
public description: string,
) {
super(`${error} - ${description}`);
}
}
export class MissingProviderError extends OauthError {
constructor() {
super(
"invalid_request",
"Must specify `provider` query parameter if `select` callback on authorizer is not specified",
);
}
}
export class MissingParameterError extends OauthError {
constructor(public parameter: string) {
super("invalid_request", `Missing parameter: ${parameter}`);
}
}
export class UnauthorizedClientError extends OauthError {
constructor(
public clientID: string,
redirectURI: string,
) {
super(
"unauthorized_client",
`Client ${clientID} is not authorized to use this redirect_uri: ${redirectURI}`,
);
}
}
export class UnknownStateError extends Error {
constructor() {
super(
"The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow",
);
}
}
export class InvalidSessionError extends Error {
constructor() {
super("Invalid session");
}
}
export class InvalidRefreshTokenError extends Error {
constructor() {
super("Invalid refresh token");
}
}
export class InvalidAuthorizationCodeError extends Error {
constructor() {
super("Invalid authorization code");
}
}
// expo root folder index.ts, remain expo-router/entry from package.json
import 'expo-router/entry';
import "react-native-url-polyfill/auto";
import './setup-crypto';
import AsyncStorage from "@react-native-async-storage/async-storage";
import { authClient } from "~/src/lib/auth";
import { type Href, router, useLocalSearchParams } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import Toast from "react-native-toast-message";
WebBrowser.maybeCompleteAuthSession();
const handleOAuthSignIn = async (provider: "google" | "facebook") => {
try {
setOauthMode(provider);
setLoading(true);
await AsyncStorage.removeItem("challenge");
const redirectURI = "app://home"; // IMPORANT: change this as per your app schema
const [challenge, url] = await authClient.pkce(redirectURI, {
provider,
});
await AsyncStorage.setItem("challenge", JSON.stringify(challenge));
const result = await WebBrowser.openAuthSessionAsync(url, redirectUrl);
if (result.type === "success") {
const resultUrl = new URL(result.url);
const code = resultUrl.searchParams.get("code");
if (!code) throw Error("No code found in the URL");
const exchanged = await authClient.exchange(
code,
redirectURI,
challenge,
);
if (!exchanged.access || !exchanged.refresh) {
throw Error("No access or refresh token found");
}
await AsyncStorage.setItem("refresh_token", exchanged.refresh);
await AsyncStorage.setItem("access_token", exchanged.access);
// TODO: fetch user details and maybe update global state
router.replace(redirectTo as Href);
}
} catch (error) {
Toast.show({
type: "error",
text1: "Authentication failed",
text2: error instanceof Error ? error.message : "Please try again",
});
} finally {
setLoading(false);
setOauthMode(null);
}
};
import * as Crypto from "expo-crypto";
import { base64url } from "jose";
function generateVerifier(length: number): string {
const buffer = new Uint8Array(length);
Crypto.getRandomValues(buffer);
return base64url.encode(buffer);
}
async function generateChallenge(verifier: string, method: "S256" | "plain") {
if (method === "plain") return verifier;
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, data);
return base64url.encode(new Uint8Array(hash));
}
export async function generatePKCE(length = 64) {
if (length < 43 || length > 128) {
throw new Error(
"Code verifier length must be between 43 and 128 characters",
);
}
const verifier = generateVerifier(length);
const challenge = await generateChallenge(verifier, "S256");
return {
verifier,
challenge,
method: "S256",
};
}
export async function validatePKCE(
verifier: string,
challenge: string,
method: "S256" | "plain" = "S256",
) {
const generatedChallenge = await generateChallenge(verifier, method);
// timing safe equals?
return generatedChallenge === challenge;
}
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { Prettify } from "./util";
export type SubjectSchema = Record<string, StandardSchemaV1>;
export type SubjectPayload<T extends SubjectSchema> = Prettify<
{
[type in keyof T & string]: {
type: type;
properties: StandardSchemaV1.InferOutput<T[type]>;
};
}[keyof T & string]
>;
export function createSubjects<Schema extends SubjectSchema = {}>(
types: Schema,
) {
return {
...types,
} as Schema;
}
// add this to root folder in expo app
// setupCrypto.ts
import * as Crypto from 'expo-crypto';
// Set up the crypto provider immediately
global.crypto = {
getRandomValues: async function(array: Uint8Array) {
const randomBytes = await Crypto.getRandomBytesAsync(array.length);
array.set(new Uint8Array(randomBytes));
return array;
},
subtle: {
digest: async function(algorithm: string, data: BufferSource) {
const inputArray = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer);
const inputString = Array.from(inputArray)
.map(b => String.fromCharCode(b))
.join('');
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
inputString
);
const hashArray = new Uint8Array(
hash.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
);
return hashArray.buffer;
}
}
} as any;
// sst/functions/src/auth.ts
import { SESv2Client } from "@aws-sdk/client-sesv2";
import { authorizer } from "@openauthjs/openauth";
import { CodeAdapter } from "@openauthjs/openauth/adapter/code";
import { GoogleOidcAdapter } from "@openauthjs/openauth/adapter/google";
import { CodeUI } from "@openauthjs/openauth/ui/code";
import { accountsTable } from "@app/core/accounts/accounts.schema";
import { db } from "@app/core/db";
import { subjects } from "@app/core/subjects";
import { handle } from "hono/aws-lambda";
import { Resource } from "sst";
import * as v from "valibot";
import { genAuthCodeEmail } from "./utils/auth-email";
const ses = new SESv2Client();
export const googleValueSchema = v.object({
iss: v.literal("https://accounts.google.com"),
azp: v.string(),
aud: v.string(),
sub: v.string(),
email: v.pipe(v.string(), v.email()),
email_verified: v.boolean(),
nonce: v.string(),
nbf: v.number(),
iat: v.number(),
exp: v.number(),
jti: v.string(),
name: v.optional(v.string()),
picture: v.optional(v.string()),
});
const app = authorizer({
subjects,
providers: {
code: CodeAdapter(
CodeUI({
async sendCode(claims, code) {
if (process.env.IS_LOCAL === "true") {
console.log("code", code);
} else {
const emailCommand = genAuthCodeEmail({
email: Resource.Email.value,
code,
});
await ses.send(emailCommand);
}
},
})
),
google: GoogleOidcAdapter({
clientID: Resource.GoogleClientId.value,
scopes: ["email"],
}),
},
theme: {
primary: "#9E77ED",
},
allow: async (input) => {
if (input.redirectURI.startsWith("app://")) return true;
const hostname = new URL(input.redirectURI).hostname;
if (hostname.startsWith("localhost") || hostname.endsWith("app.com")) // IMPORTANT: change this as per your website url
return true;
return false;
},
async success(ctx, value) {
if (value.provider === "google") {
const payload = v.parse(googleValueSchema, value.id);
if (!payload.email) throw new Error("No email found");
let account = await db.query.accountsTable.findFirst({
where: (account, { eq }) => eq(account.email, payload.email),
});
if (!account?.id) {
const result = await db
.insert(accountsTable)
.values({
name: payload.name,
email: payload.email,
isVerified: payload.email_verified,
image: payload.picture,
})
.returning();
account = result[0];
}
if (account) {
return ctx.subject("user", {
accountId: account.id,
role: account.role,
});
}
}
if (value.provider === "code") {
const email = value.claims.email;
let account = await db.query.accountsTable.findFirst({
where: (account, { eq }) => eq(account.email, email),
});
if (!account?.id) {
const result = await db
.insert(accountsTable)
.values({
email,
isVerified: true,
})
.returning();
account = result[0];
}
if (account) {
return ctx.subject("user", {
accountId: account.id,
});
}
}
throw new Error("Invalid provider");
},
error(error, req) {
throw error;
},
});
import type { Context } from "hono";
export type Prettify<T> = {
[K in keyof T]: T[K];
};
export function getRelativeUrl(ctx: Context, path: string) {
const result = new URL(path, ctx.req.url);
result.host = ctx.req.header("x-forwarded-host") || result.host;
return result.toString();
}
const twoPartTlds = [
"co.uk",
"co.jp",
"co.kr",
"co.nz",
"co.za",
"co.in",
"com.au",
"com.br",
"com.cn",
"com.mx",
"com.tw",
"net.au",
"org.uk",
"ne.jp",
"ac.uk",
"gov.uk",
"edu.au",
"gov.au",
];
export function isDomainMatch(a: string, b: string): boolean {
if (a === b) return true;
const partsA = a.split(".");
const partsB = b.split(".");
const hasTwoPartTld = twoPartTlds.some(
(tld) => a.endsWith(`.${tld}`) || b.endsWith(`.${tld}`),
);
const numParts = hasTwoPartTld ? -3 : -2;
const min = Math.min(partsA.length, partsB.length, numParts);
const tailA = partsA.slice(min).join(".");
const tailB = partsB.slice(min).join(".");
return tailA === tailB;
}
@fredericrous
Copy link

fredericrous commented Dec 27, 2024

thank you for this gist. Small suggestion: you can revert back the pkce file to its original state because you already polyfilled global.crypto
you are all good if you import the polyfill before client.ts

also if you use a more recent version of client.ts, it now uses crypto.randomUUID, so you'll need to polyfill that too in setup-crypto.ts

  randomUUID: Crypto.randomUUID

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment