Files
Job-Info/ModeTemplate/backend/modules/auth/microsoft-oauth.ts
T
aewing 05f8e2e3b6 Add ModeTemplate with Svelte 5 frontend and components
- Create reusable components: auth, api-client, shared-types
- ModeTemplate with Hono backend and SvelteKit frontend
- Auth store refactored to class-based Svelte 5 pattern
- SSR-safe hydrate() pattern for localStorage access
- Frontend builds successfully with Svelte 5 runes
2026-01-21 05:12:25 +00:00

209 lines
5.1 KiB
TypeScript

/**
* Microsoft OAuth Module
*
* Handles Microsoft OAuth flow to authenticate users and retrieve:
* - User's email
* - User's display name
* - PocketBase auth token (user must already exist)
*
* Environment Variables Used:
* - MICROSOFT_CLIENT_ID
* - MICROSOFT_CLIENT_SECRET
* - MICROSOFT_TENANT_ID
* - MICROSOFT_REDIRECT_URI
* - MICROSOFT_SCOPES
* - PB_URL
*/
interface MicrosoftTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
refresh_token?: string;
}
interface MicrosoftUserInfo {
id: string;
displayName: string;
mail: string;
userPrincipalName: string;
}
export interface AuthResult {
email: string;
displayName: string;
pocketbaseToken: string;
}
export interface OAuthConfig {
clientId: string;
clientSecret: string;
tenantId: string;
redirectUri: string;
scopes: string;
pbUrl: string;
}
/**
* Build config from process.env
*/
export function getConfigFromEnv(): OAuthConfig {
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
const tenantId = process.env.MICROSOFT_TENANT_ID;
const redirectUri = process.env.MICROSOFT_REDIRECT_URI;
const scopes = process.env.MICROSOFT_SCOPES;
const pbUrl = process.env.PB_URL;
if (!clientId || !clientSecret || !tenantId || !redirectUri || !scopes || !pbUrl) {
throw new Error('Missing required OAuth environment variables');
}
return { clientId, clientSecret, tenantId, redirectUri, scopes, pbUrl };
}
/**
* Generate the Microsoft OAuth authorization URL
*/
export function getAuthUrl(config: OAuthConfig): string {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: 'code',
redirect_uri: config.redirectUri,
response_mode: 'query',
scope: config.scopes,
state: crypto.randomUUID(),
});
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize?${params}`;
}
/**
* Exchange authorization code for access token
*/
export async function exchangeCodeForToken(
code: string,
config: OAuthConfig
): Promise<MicrosoftTokenResponse> {
const response = await fetch(
`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
redirect_uri: config.redirectUri,
grant_type: 'authorization_code',
scope: config.scopes,
}),
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json();
}
/**
* Get user info from Microsoft Graph API
*/
export async function getMicrosoftUserInfo(
accessToken: string
): Promise<MicrosoftUserInfo> {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to get user info: ${error}`);
}
return response.json();
}
/**
* Authenticate existing user with PocketBase
* User MUST already exist - no user creation
*/
export async function getPocketBaseToken(
email: string,
pbUrl: string
): Promise<string> {
// Look up user by email - user must exist
const searchResponse = await fetch(
`${pbUrl}/api/collections/users/records?filter=(email='${encodeURIComponent(email)}')`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
if (!searchResponse.ok) {
throw new Error('Failed to query PocketBase');
}
const searchData = await searchResponse.json();
if (!searchData.items || searchData.items.length === 0) {
throw new Error('Access denied: User does not exist');
}
// User exists - authenticate via OAuth
const authResponse = await fetch(`${pbUrl}/api/collections/users/auth-with-oauth2`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'microsoft',
}),
});
if (!authResponse.ok) {
const error = await authResponse.text();
throw new Error(`PocketBase auth failed: ${error}`);
}
const data = await authResponse.json();
return data.token;
}
/**
* Complete OAuth flow: code -> user info + PocketBase token
* Denies access if user doesn't exist in PocketBase
*/
export async function handleCallback(
code: string,
config: OAuthConfig
): Promise<AuthResult> {
// 1. Exchange code for Microsoft access token
const tokenResponse = await exchangeCodeForToken(code, config);
// 2. Get user info from Microsoft Graph
const userInfo = await getMicrosoftUserInfo(tokenResponse.access_token);
const email = userInfo.mail || userInfo.userPrincipalName;
const displayName = userInfo.displayName;
// 3. Get PocketBase auth token (user must exist)
const pocketbaseToken = await getPocketBaseToken(email, config.pbUrl);
return {
email,
displayName,
pocketbaseToken,
};
}