first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
/**
* OAuth consumer - "Login with X" functionality
*/
import { sha256 } from "@oslojs/crypto/sha2";
import { encodeBase64urlNoPadding } from "@oslojs/encoding";
import { z } from "zod";
import type { AuthAdapter, User, RoleLevel } from "../types.js";
import { github, fetchGitHubEmail } from "./providers/github.js";
import { google } from "./providers/google.js";
import type { OAuthProvider, OAuthConfig, OAuthProfile, OAuthState } from "./types.js";
export { github, google };
export interface OAuthConsumerConfig {
baseUrl: string;
providers: {
github?: OAuthConfig;
google?: OAuthConfig;
};
/**
* Check if self-signup is allowed for this email domain
*/
canSelfSignup?: (email: string) => Promise<{ allowed: boolean; role: RoleLevel } | null>;
}
/**
* Generate an OAuth authorization URL
*/
export async function createAuthorizationUrl(
config: OAuthConsumerConfig,
providerName: "github" | "google",
stateStore: StateStore,
): Promise<{ url: string; state: string }> {
const providerConfig = config.providers[providerName];
if (!providerConfig) {
throw new Error(`OAuth provider ${providerName} not configured`);
}
const provider = getProvider(providerName);
const state = generateState();
const redirectUri = `${config.baseUrl}/api/auth/oauth/${providerName}/callback`;
// Generate PKCE code verifier for providers that support it
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store state for verification
await stateStore.set(state, {
provider: providerName,
redirectUri,
codeVerifier,
});
// Build authorization URL
const url = new URL(provider.authorizeUrl);
url.searchParams.set("client_id", providerConfig.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", provider.scopes.join(" "));
url.searchParams.set("state", state);
// PKCE for all providers (GitHub has supported S256 since 2021)
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return { url: url.toString(), state };
}
/**
* Handle OAuth callback
*/
export async function handleOAuthCallback(
config: OAuthConsumerConfig,
adapter: AuthAdapter,
providerName: "github" | "google",
code: string,
state: string,
stateStore: StateStore,
): Promise<User> {
const providerConfig = config.providers[providerName];
if (!providerConfig) {
throw new Error(`OAuth provider ${providerName} not configured`);
}
// Verify state
const storedState = await stateStore.get(state);
if (!storedState || storedState.provider !== providerName) {
throw new OAuthError("invalid_state", "Invalid OAuth state");
}
// Delete state (single-use)
await stateStore.delete(state);
const provider = getProvider(providerName);
// Exchange code for tokens
const tokens = await exchangeCode(
provider,
providerConfig,
code,
storedState.redirectUri,
storedState.codeVerifier,
);
// Fetch user profile
const profile = await fetchProfile(provider, tokens.accessToken, providerName);
// Find or create user
return findOrCreateUser(config, adapter, providerName, profile);
}
/**
* Exchange authorization code for tokens
*/
async function exchangeCode(
provider: OAuthProvider,
config: OAuthConfig,
code: string,
redirectUri: string,
codeVerifier?: string,
): Promise<{ accessToken: string; idToken?: string }> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
});
if (codeVerifier) {
body.set("code_verifier", codeVerifier);
}
const response = await fetch(provider.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
if (!response.ok) {
const error = await response.text();
throw new OAuthError("token_exchange_failed", `Token exchange failed: ${error}`);
}
const json: unknown = await response.json();
const data = z
.object({
access_token: z.string(),
id_token: z.string().optional(),
})
.parse(json);
return {
accessToken: data.access_token,
idToken: data.id_token,
};
}
/**
* Fetch user profile from OAuth provider
*/
async function fetchProfile(
provider: OAuthProvider,
accessToken: string,
providerName: string,
): Promise<OAuthProfile> {
if (!provider.userInfoUrl) {
throw new Error("Provider does not have userinfo URL");
}
const response = await fetch(provider.userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (!response.ok) {
throw new OAuthError("profile_fetch_failed", `Failed to fetch profile: ${response.status}`);
}
const data = await response.json();
const profile = provider.parseProfile(data);
// GitHub may not return email in main profile
if (providerName === "github" && !profile.email) {
profile.email = await fetchGitHubEmail(accessToken);
}
return profile;
}
/**
* Find existing user or create new one (with auto-linking)
*/
async function findOrCreateUser(
config: OAuthConsumerConfig,
adapter: AuthAdapter,
providerName: string,
profile: OAuthProfile,
): Promise<User> {
// Check if OAuth account already linked
const existingAccount = await adapter.getOAuthAccount(providerName, profile.id);
if (existingAccount) {
const user = await adapter.getUserById(existingAccount.userId);
if (!user) {
throw new OAuthError("user_not_found", "Linked user not found");
}
return user;
}
// Check if user with this email exists (auto-link)
// Only auto-link when the provider has verified the email to prevent
// account takeover via unverified email on a third-party provider
const existingUser = await adapter.getUserByEmail(profile.email);
if (existingUser) {
if (!profile.emailVerified) {
throw new OAuthError(
"signup_not_allowed",
"Cannot link account: email not verified by provider",
);
}
await adapter.createOAuthAccount({
provider: providerName,
providerAccountId: profile.id,
userId: existingUser.id,
});
return existingUser;
}
// Check if self-signup is allowed
if (config.canSelfSignup) {
const signup = await config.canSelfSignup(profile.email);
if (signup?.allowed) {
// Create new user
const user = await adapter.createUser({
email: profile.email,
name: profile.name,
avatarUrl: profile.avatarUrl,
role: signup.role,
emailVerified: profile.emailVerified,
});
// Link OAuth account
await adapter.createOAuthAccount({
provider: providerName,
providerAccountId: profile.id,
userId: user.id,
});
return user;
}
}
throw new OAuthError("signup_not_allowed", "Self-signup not allowed for this email domain");
}
function getProvider(name: "github" | "google"): OAuthProvider {
switch (name) {
case "github":
return github;
case "google":
return google;
}
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Generate a random state string for OAuth CSRF protection
*/
function generateState(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return encodeBase64urlNoPadding(bytes);
}
function generateCodeVerifier(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return encodeBase64urlNoPadding(bytes);
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const bytes = new TextEncoder().encode(verifier);
const hash = sha256(bytes);
return encodeBase64urlNoPadding(hash);
}
// ============================================================================
// State storage interface
// ============================================================================
export interface StateStore {
set(state: string, data: OAuthState): Promise<void>;
get(state: string): Promise<OAuthState | null>;
delete(state: string): Promise<void>;
}
// ============================================================================
// Errors
// ============================================================================
export class OAuthError extends Error {
constructor(
public code:
| "invalid_state"
| "token_exchange_failed"
| "profile_fetch_failed"
| "user_not_found"
| "signup_not_allowed",
message: string,
) {
super(message);
this.name = "OAuthError";
}
}

View File

@@ -0,0 +1,68 @@
/**
* GitHub OAuth provider
*/
import { z } from "zod";
import type { OAuthProvider, OAuthProfile } from "../types.js";
const gitHubUserSchema = z.object({
id: z.number(),
login: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
avatar_url: z.string(),
});
const gitHubEmailSchema = z.object({
email: z.string(),
primary: z.boolean(),
verified: z.boolean(),
});
export const github: OAuthProvider = {
name: "github",
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
scopes: ["read:user", "user:email"],
parseProfile(data: unknown): OAuthProfile {
const user = gitHubUserSchema.parse(data);
return {
id: String(user.id),
email: user.email || "", // Will be fetched separately if needed
name: user.name,
avatarUrl: user.avatar_url,
emailVerified: true, // GitHub verifies emails
};
},
};
/**
* Fetch the user's primary email from GitHub
* (needed because email may not be returned in the basic user endpoint)
*/
export async function fetchGitHubEmail(accessToken: string): Promise<string> {
const response = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch GitHub emails: ${response.status}`);
}
const json: unknown = await response.json();
const emails = z.array(gitHubEmailSchema).parse(json);
const primary = emails.find((e) => e.primary && e.verified);
if (!primary) {
throw new Error("No verified primary email found on GitHub account");
}
return primary.email;
}

View File

@@ -0,0 +1,34 @@
/**
* Google OAuth provider (using OIDC)
*/
import { z } from "zod";
import type { OAuthProvider, OAuthProfile } from "../types.js";
const googleUserSchema = z.object({
sub: z.string(),
email: z.string(),
email_verified: z.boolean(),
name: z.string(),
picture: z.string(),
});
export const google: OAuthProvider = {
name: "google",
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
scopes: ["openid", "email", "profile"],
parseProfile(data: unknown): OAuthProfile {
const user = googleUserSchema.parse(data);
return {
id: user.sub,
email: user.email,
name: user.name,
avatarUrl: user.picture,
emailVerified: user.email_verified,
};
},
};

View File

@@ -0,0 +1,36 @@
/**
* OAuth types
*/
export interface OAuthProfile {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
emailVerified: boolean;
}
export interface OAuthProvider {
name: string;
authorizeUrl: string;
tokenUrl: string;
userInfoUrl?: string;
scopes: string[];
/**
* Parse the user profile from the provider's response
*/
parseProfile(data: unknown): OAuthProfile;
}
export interface OAuthConfig {
clientId: string;
clientSecret: string;
}
export interface OAuthState {
provider: string;
redirectUri: string;
codeVerifier?: string; // For PKCE
nonce?: string;
}