mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
add sign in
This commit is contained in:
@@ -196,6 +196,25 @@ shortlink auth works out of the box — no extra configuration needed.
|
||||
|
||||
Prod works the same way: `heygo.cc` and `my.heygo.cc` share `.heygo.cc`.
|
||||
|
||||
### Google sign-in
|
||||
|
||||
Create a Google OAuth web client and add these authorized redirect URIs:
|
||||
|
||||
- Dev: `https://dev.heygo.cc/api/auth/google/callback`
|
||||
- Prod: `https://heygo.cc/api/auth/google/callback`
|
||||
|
||||
Then set the Worker secrets for the target environment:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put GOOGLE_CLIENT_ID --config wrangler.dev.jsonc
|
||||
npx wrangler secret put GOOGLE_CLIENT_SECRET --config wrangler.dev.jsonc
|
||||
```
|
||||
|
||||
Repeat with `wrangler.prod.jsonc` for production. The Google sign-in flow uses
|
||||
the `openid email profile` scopes, stores the Google account ID in
|
||||
`oauth_accounts`, creates a `heygo_session` cookie, and promotes matching
|
||||
`ADMIN_EMAILS` users to admin.
|
||||
|
||||
### All deployment commands
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// Login provider buttons. OAuth callback handlers are not implemented yet
|
||||
// (Task 10 scope is UI only); the buttons are real anchor links to the
|
||||
// provider auth start endpoints so they light up once the OAuth flow lands.
|
||||
// They are not fake/JS auth — clicking performs a full-page navigation.
|
||||
|
||||
interface ProviderInfo {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
@@ -32,10 +27,6 @@ export default function ProviderButtons({ disabled = false }: ProviderButtonsPro
|
||||
<span className="provider-button__label">{provider.label}</span>
|
||||
</a>
|
||||
))}
|
||||
<p className="provider-note">
|
||||
OAuth sign-in is not wired up yet. These buttons start the provider flow once the
|
||||
callback handlers ship.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import worker from '../worker/index';
|
||||
import { AUTH_SESSION_COOKIE_NAME } from '../worker/auth';
|
||||
import type { Env } from '../worker/env';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
image_url: string | null;
|
||||
role: 'user' | 'admin';
|
||||
};
|
||||
|
||||
type OAuthAccountRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
provider: string;
|
||||
provider_account_id: string;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
session_token_hash: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
class FakeOAuthD1 {
|
||||
readonly users: UserRow[] = [];
|
||||
readonly oauthAccounts: OAuthAccountRow[] = [];
|
||||
readonly sessions: SessionRow[] = [];
|
||||
|
||||
prepare(sql: string): FakeOAuthStatement {
|
||||
return new FakeOAuthStatement(this, sql);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOAuthStatement {
|
||||
private params: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly db: FakeOAuthD1,
|
||||
private readonly sql: string,
|
||||
) {}
|
||||
|
||||
bind(...params: unknown[]): this {
|
||||
this.params = params;
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
if (this.sql.includes('FROM oauth_accounts JOIN users')) {
|
||||
const [provider, providerAccountId] = this.params.map(String);
|
||||
const account = this.db.oauthAccounts.find(
|
||||
(row) => row.provider === provider && row.provider_account_id === providerAccountId,
|
||||
);
|
||||
const user = account ? this.db.users.find((row) => row.id === account.user_id) : undefined;
|
||||
return (user ?? null) as T | null;
|
||||
}
|
||||
|
||||
if (this.sql.includes('FROM users WHERE lower(email)')) {
|
||||
const email = String(this.params[0]).toLowerCase();
|
||||
const user = this.db.users.find((row) => row.email.toLowerCase() === email);
|
||||
return (user ?? null) as T | null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async run(): Promise<D1Result> {
|
||||
if (this.sql.startsWith('INSERT INTO users')) {
|
||||
const [id, email, name, imageUrl, role] = this.params;
|
||||
this.db.users.push({
|
||||
id: String(id),
|
||||
email: String(email),
|
||||
name: name == null ? null : String(name),
|
||||
image_url: imageUrl == null ? null : String(imageUrl),
|
||||
role: role as 'user' | 'admin',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('UPDATE users SET name')) {
|
||||
const [name, imageUrl, id] = this.params;
|
||||
const user = this.db.users.find((row) => row.id === id);
|
||||
if (user) {
|
||||
if (name != null) user.name = String(name);
|
||||
if (imageUrl != null) user.image_url = String(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('UPDATE users SET role')) {
|
||||
const [role, id] = this.params;
|
||||
const user = this.db.users.find((row) => row.id === id);
|
||||
if (user) {
|
||||
user.role = role as 'user' | 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('INSERT OR IGNORE INTO oauth_accounts')) {
|
||||
const [id, userId, provider, providerAccountId] = this.params.map(String);
|
||||
const exists = this.db.oauthAccounts.some(
|
||||
(row) => row.provider === provider && row.provider_account_id === providerAccountId,
|
||||
);
|
||||
if (!exists) {
|
||||
this.db.oauthAccounts.push({ id, user_id: userId, provider, provider_account_id: providerAccountId });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('INSERT INTO sessions')) {
|
||||
const [id, userId, sessionTokenHash, expiresAt] = this.params.map(String);
|
||||
this.db.sessions.push({
|
||||
id,
|
||||
user_id: userId,
|
||||
session_token_hash: sessionTokenHash,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, meta: {} } as D1Result;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeExecutionContext {
|
||||
waitUntil(): void {}
|
||||
passThroughOnException(): void {}
|
||||
}
|
||||
|
||||
function makeEnv(db: FakeOAuthD1): Env {
|
||||
return {
|
||||
DB: db as unknown as D1Database,
|
||||
PUBLIC_HOST: 'dev.heygo.cc',
|
||||
PRIVATE_HOST: 'my.dev.heygo.cc',
|
||||
APP_BASE_URL: 'https://dev.heygo.cc',
|
||||
COOKIE_DOMAIN: '.heygo.cc',
|
||||
ADMIN_EMAILS: 'wahyd4@gmail.com',
|
||||
GOOGLE_CLIENT_ID: 'google-client-id',
|
||||
GOOGLE_CLIENT_SECRET: 'google-client-secret',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorker(path: string, env: Env, init: RequestInit = {}): Promise<Response> {
|
||||
const request = new Request(`https://dev.heygo.cc${path}`, init);
|
||||
return worker.fetch(
|
||||
request as unknown as Parameters<typeof worker.fetch>[0],
|
||||
env as unknown as Parameters<typeof worker.fetch>[1],
|
||||
new FakeExecutionContext() as unknown as Parameters<typeof worker.fetch>[2],
|
||||
);
|
||||
}
|
||||
|
||||
describe('Google OAuth API', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === 'https://oauth2.googleapis.com/token') {
|
||||
return Response.json({ access_token: 'google-access-token', token_type: 'Bearer', expires_in: 3600 });
|
||||
}
|
||||
if (url === 'https://openidconnect.googleapis.com/v1/userinfo') {
|
||||
return Response.json({
|
||||
sub: 'google-user-1',
|
||||
email: 'wahyd4@gmail.com',
|
||||
email_verified: true,
|
||||
name: 'Wahyd',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
});
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('redirects to Google with a state cookie', async () => {
|
||||
const db = new FakeOAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker('/api/auth/google', env);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.headers.get('location')!);
|
||||
expect(location.origin + location.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
expect(location.searchParams.get('client_id')).toBe('google-client-id');
|
||||
expect(location.searchParams.get('redirect_uri')).toBe('https://dev.heygo.cc/api/auth/google/callback');
|
||||
expect(location.searchParams.get('scope')).toBe('openid email profile');
|
||||
expect(location.searchParams.get('state')).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(response.headers.get('set-cookie')).toContain('heygo_oauth_state=');
|
||||
expect(response.headers.get('set-cookie')).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('creates an admin user and session on callback', async () => {
|
||||
const db = new FakeOAuthD1();
|
||||
const env = makeEnv(db);
|
||||
const state = 'a'.repeat(64);
|
||||
|
||||
const response = await fetchWorker(
|
||||
`/api/auth/google/callback?code=oauth-code&state=${state}`,
|
||||
env,
|
||||
{ headers: { cookie: `heygo_oauth_state=${state}` } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toBe('https://dev.heygo.cc/app/private');
|
||||
expect(response.headers.get('set-cookie')).toContain(`${AUTH_SESSION_COOKIE_NAME}=`);
|
||||
expect(db.users).toHaveLength(1);
|
||||
expect(db.users[0]).toMatchObject({
|
||||
email: 'wahyd4@gmail.com',
|
||||
name: 'Wahyd',
|
||||
image_url: 'https://example.com/avatar.png',
|
||||
role: 'admin',
|
||||
});
|
||||
expect(db.oauthAccounts).toHaveLength(1);
|
||||
expect(db.oauthAccounts[0]).toMatchObject({
|
||||
user_id: db.users[0].id,
|
||||
provider: 'google',
|
||||
provider_account_id: 'google-user-1',
|
||||
});
|
||||
expect(db.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a callback with an invalid state', async () => {
|
||||
const db = new FakeOAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/google/callback?code=oauth-code&state=wrong',
|
||||
env,
|
||||
{ headers: { cookie: `heygo_oauth_state=${'a'.repeat(64)}` } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('location')).toContain('/app/login');
|
||||
expect(db.users).toHaveLength(0);
|
||||
expect(db.sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -7,4 +7,6 @@ export interface Env {
|
||||
APP_BASE_URL: string;
|
||||
COOKIE_DOMAIN: string;
|
||||
ADMIN_EMAILS?: string;
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_SECRET?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { withPrivateNoStoreHeaders } from './lib/responses';
|
||||
import { handleAuthApi } from './routes/api.auth';
|
||||
import { handleDevAuth } from './routes/api.dev-auth';
|
||||
import { handleLinksApi } from './routes/api.links';
|
||||
import { handleOAuthApi } from './routes/api.oauth';
|
||||
import { handleLinkGoRoute } from './routes/link-go';
|
||||
import { handlePromotionsApi } from './routes/api.promotions';
|
||||
import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect';
|
||||
@@ -101,6 +102,10 @@ export default {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const oauthResponse = await handleOAuthApi(request, env);
|
||||
if (oauthResponse) {
|
||||
return withPrivateHostNoStoreHeaders(url, oauthResponse, env.PRIVATE_HOST);
|
||||
}
|
||||
const authResponse = await handleAuthApi(request, env);
|
||||
if (authResponse) {
|
||||
return withPrivateHostNoStoreHeaders(url, authResponse, env.PRIVATE_HOST);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
AUTH_SESSION_COOKIE_NAME,
|
||||
getSessionCookieAttributes,
|
||||
hashSessionToken,
|
||||
isConfiguredAdminEmail,
|
||||
} from '../auth';
|
||||
import type { Env } from '../env';
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
|
||||
const GOOGLE_SCOPES = 'openid email profile';
|
||||
const OAUTH_STATE_COOKIE_NAME = 'heygo_oauth_state';
|
||||
const SESSION_TTL_DAYS = 30;
|
||||
|
||||
const FIND_OAUTH_USER = `SELECT users.id, users.email, users.name, users.image_url, users.role
|
||||
FROM oauth_accounts JOIN users ON users.id = oauth_accounts.user_id
|
||||
WHERE oauth_accounts.provider = ? AND oauth_accounts.provider_account_id = ?
|
||||
LIMIT 1`;
|
||||
const FIND_USER_BY_EMAIL = `SELECT id, email, name, image_url, role FROM users WHERE lower(email) = lower(?) LIMIT 1`;
|
||||
const CREATE_USER = `INSERT INTO users (id, email, name, image_url, role) VALUES (?, ?, ?, ?, ?)`;
|
||||
const UPDATE_USER_PROFILE = `UPDATE users SET name = COALESCE(?, name), image_url = COALESCE(?, image_url), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`;
|
||||
const UPDATE_USER_ROLE = `UPDATE users SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`;
|
||||
const CREATE_OAUTH_ACCOUNT = `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_account_id) VALUES (?, ?, ?, ?)`;
|
||||
const CREATE_SESSION = `INSERT INTO sessions (id, user_id, session_token_hash, expires_at) VALUES (?, ?, ?, ?)`;
|
||||
|
||||
type OAuthUserRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image_url: string | null;
|
||||
role: 'user' | 'admin';
|
||||
};
|
||||
|
||||
type GoogleTokenResponse = {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
type GoogleUserInfo = {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export async function handleOAuthApi(request: Request, env: Env): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/api/auth/google') {
|
||||
return handleGoogleStart(request, env);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/google/callback') {
|
||||
return handleGoogleCallback(request, env);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleGoogleStart(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
const credentialError = requireGoogleCredentials(env);
|
||||
if (credentialError) {
|
||||
return credentialError;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const state = generateToken();
|
||||
const redirectUri = googleRedirectUri(env);
|
||||
const loginHint = requestUrl.searchParams.get('login_hint') ?? env.ADMIN_EMAILS?.split(/[\s,;]+/)[0] ?? '';
|
||||
const googleUrl = new URL(GOOGLE_AUTH_URL);
|
||||
googleUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID!);
|
||||
googleUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
googleUrl.searchParams.set('response_type', 'code');
|
||||
googleUrl.searchParams.set('scope', GOOGLE_SCOPES);
|
||||
googleUrl.searchParams.set('state', state);
|
||||
googleUrl.searchParams.set('include_granted_scopes', 'true');
|
||||
if (loginHint) {
|
||||
googleUrl.searchParams.set('login_hint', loginHint);
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: googleUrl.toString(),
|
||||
'set-cookie': oauthStateCookie(request.url, env, state),
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGoogleCallback(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
const credentialError = requireGoogleCredentials(env);
|
||||
if (credentialError) {
|
||||
return credentialError;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const error = url.searchParams.get('error');
|
||||
if (error) {
|
||||
return redirectWithAuthError(env, `Google sign-in failed: ${error}`);
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const storedState = parseCookieHeader(request.headers.get('cookie'))[OAUTH_STATE_COOKIE_NAME];
|
||||
if (!code || !state || !storedState || !timingSafeEqual(state, storedState)) {
|
||||
return redirectWithAuthError(env, 'Invalid sign-in state. Please try again.');
|
||||
}
|
||||
|
||||
const token = await exchangeGoogleCode(env, code);
|
||||
if (!token.access_token) {
|
||||
return redirectWithAuthError(env, token.error_description ?? token.error ?? 'Google token exchange failed.');
|
||||
}
|
||||
|
||||
const profile = await fetchGoogleUserInfo(token.access_token);
|
||||
if (!profile.sub || !profile.email || profile.email_verified !== true) {
|
||||
return redirectWithAuthError(env, 'Google account email is not verified.');
|
||||
}
|
||||
|
||||
const user = await findOrCreateOAuthUser(env, profile);
|
||||
const sessionToken = generateToken();
|
||||
const tokenHash = await hashSessionToken(sessionToken);
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
||||
await env.DB.prepare(CREATE_SESSION).bind(crypto.randomUUID(), user.id, tokenHash, expiresAt).run();
|
||||
|
||||
const sessionCookie = `${AUTH_SESSION_COOKIE_NAME}=${sessionToken}; ${getSessionCookieAttributes(url, env.COOKIE_DOMAIN).join('; ')}`;
|
||||
const clearStateCookie = oauthStateCookie(request.url, env, '', true);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: [
|
||||
['location', `${env.APP_BASE_URL}/app/private`],
|
||||
['set-cookie', sessionCookie],
|
||||
['set-cookie', clearStateCookie],
|
||||
['cache-control', 'no-store'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function requireGoogleCredentials(env: Env): Response | null {
|
||||
if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) {
|
||||
return Response.json(
|
||||
{ error: 'Google OAuth is not configured' },
|
||||
{ status: 503, headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function exchangeGoogleCode(env: Env, code: string): Promise<GoogleTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
client_id: env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: env.GOOGLE_CLIENT_SECRET!,
|
||||
redirect_uri: googleRedirectUri(env),
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
return (await response.json()) as GoogleTokenResponse;
|
||||
}
|
||||
|
||||
async function fetchGoogleUserInfo(accessToken: string): Promise<GoogleUserInfo> {
|
||||
const response = await fetch(GOOGLE_USERINFO_URL, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
return (await response.json()) as GoogleUserInfo;
|
||||
}
|
||||
|
||||
async function findOrCreateOAuthUser(env: Env, profile: GoogleUserInfo): Promise<OAuthUserRow> {
|
||||
const provider = 'google';
|
||||
const providerAccountId = profile.sub!;
|
||||
const email = profile.email!;
|
||||
const configuredAdmin = isConfiguredAdminEmail(email, env);
|
||||
const role = configuredAdmin ? 'admin' : 'user';
|
||||
|
||||
let user = await env.DB.prepare(FIND_OAUTH_USER).bind(provider, providerAccountId).first<OAuthUserRow>();
|
||||
if (!user) {
|
||||
user = await env.DB.prepare(FIND_USER_BY_EMAIL).bind(email).first<OAuthUserRow>();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
id: crypto.randomUUID(),
|
||||
email,
|
||||
name: profile.name ?? email.split('@')[0],
|
||||
image_url: profile.picture ?? null,
|
||||
role,
|
||||
};
|
||||
await env.DB.prepare(CREATE_USER).bind(user.id, user.email, user.name, user.image_url, user.role).run();
|
||||
} else {
|
||||
await env.DB.prepare(UPDATE_USER_PROFILE).bind(profile.name ?? null, profile.picture ?? null, user.id).run();
|
||||
if (configuredAdmin && user.role !== 'admin') {
|
||||
await env.DB.prepare(UPDATE_USER_ROLE).bind('admin', user.id).run();
|
||||
user = { ...user, role: 'admin' };
|
||||
}
|
||||
}
|
||||
|
||||
await env.DB.prepare(CREATE_OAUTH_ACCOUNT)
|
||||
.bind(crypto.randomUUID(), user.id, provider, providerAccountId)
|
||||
.run();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function googleRedirectUri(env: Env): string {
|
||||
return `${env.APP_BASE_URL}/api/auth/google/callback`;
|
||||
}
|
||||
|
||||
function oauthStateCookie(requestUrl: string | URL, env: Env, value: string, expired = false): string {
|
||||
const attrs = getSessionCookieAttributes(requestUrl, env.COOKIE_DOMAIN);
|
||||
if (expired) {
|
||||
attrs.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
|
||||
} else {
|
||||
attrs.push('Max-Age=600');
|
||||
}
|
||||
return `${OAUTH_STATE_COOKIE_NAME}=${value}; ${attrs.join('; ')}`;
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader: string | null): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const index = part.indexOf('=');
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
cookies[part.slice(0, index).trim()] = part.slice(index + 1).trim();
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function redirectWithAuthError(env: Env, message: string): Response {
|
||||
const location = new URL(`${env.APP_BASE_URL}/app/login`);
|
||||
location.searchParams.set('error', message);
|
||||
return Response.redirect(location.toString(), 302);
|
||||
}
|
||||
Reference in New Issue
Block a user