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
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user