first commit
This commit is contained in:
324
packages/auth/src/oauth/consumer.ts
Normal file
324
packages/auth/src/oauth/consumer.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
68
packages/auth/src/oauth/providers/github.ts
Normal file
68
packages/auth/src/oauth/providers/github.ts
Normal 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;
|
||||
}
|
||||
34
packages/auth/src/oauth/providers/google.ts
Normal file
34
packages/auth/src/oauth/providers/google.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
36
packages/auth/src/oauth/types.ts
Normal file
36
packages/auth/src/oauth/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user