05f8e2e3b6
- 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
209 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|