From ebd3e0928ab7e49984973021db9a781b2503adbb Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Mon, 16 Jun 2025 08:54:23 +0700 Subject: [PATCH] feat: fix github auth flow --- apps/api/src/api-v1/auth/github-callback.ts | 28 ++++++++ .../auth/{github.ts => github-exchange.ts} | 4 +- apps/api/src/api-v1/auth/github-init.ts | 71 +++++++++++++++++++ apps/api/src/api-v1/auth/utils.ts | 3 + apps/api/src/api-v1/index.ts | 8 ++- ...h-storage-temp => drizzle-auth-storage.ts} | 0 apps/api/src/lib/env.ts | 6 +- apps/api/src/server.ts | 4 +- packages/api-client/src/agentic-api-client.ts | 10 +-- packages/types/src/openapi.d.ts | 47 +++++++++++- 10 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/api-v1/auth/github-callback.ts rename apps/api/src/api-v1/auth/{github.ts => github-exchange.ts} (97%) create mode 100644 apps/api/src/api-v1/auth/github-init.ts create mode 100644 apps/api/src/api-v1/auth/utils.ts rename apps/api/src/lib/{drizzle-auth-storage-temp => drizzle-auth-storage.ts} (100%) diff --git a/apps/api/src/api-v1/auth/github-callback.ts b/apps/api/src/api-v1/auth/github-callback.ts new file mode 100644 index 00000000..5509da20 --- /dev/null +++ b/apps/api/src/api-v1/auth/github-callback.ts @@ -0,0 +1,28 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' +import type { OpenAPIHono } from '@hono/zod-openapi' +import { assert } from '@agentic/platform-core' + +import { authStorage } from './utils' + +export function registerV1AuthGitHubOAuthCallback( + app: OpenAPIHono +) { + return app.get('auth/github/callback', async (c) => { + const logger = c.get('logger') + const query = c.req.query() + + assert(query.state, 400, 'State is required') + + const entry = await authStorage.get(['github', query.state, 'redirectUri']) + assert(entry, 400, 'Redirect URI not found') + const redirectUri = entry.redirectUri + assert(entry.redirectUri, 400, 'Redirect URI not found') + + const url = new URL( + `${redirectUri}?${new URLSearchParams(query).toString()}` + ).toString() + logger.info('GitHub auth callback', query, '=>', url) + + return c.redirect(url) + }) +} diff --git a/apps/api/src/api-v1/auth/github.ts b/apps/api/src/api-v1/auth/github-exchange.ts similarity index 97% rename from apps/api/src/api-v1/auth/github.ts rename to apps/api/src/api-v1/auth/github-exchange.ts index 5f5921bf..67016dde 100644 --- a/apps/api/src/api-v1/auth/github.ts +++ b/apps/api/src/api-v1/auth/github-exchange.ts @@ -21,7 +21,7 @@ const route = createRoute({ tags: ['auth'], operationId: 'exchangeOAuthCodeWithGitHub', method: 'post', - path: 'auth/github', + path: 'auth/github/exchange', security: openapiAuthenticatedSecuritySchemas, request: { body: { @@ -51,7 +51,7 @@ const route = createRoute({ } }) -export function registerV1AuthExchangeOAuthCodeWithGitHub( +export function registerV1AuthGitHubOAuthExchange( app: OpenAPIHono ) { return app.openapi(route, async (c) => { diff --git a/apps/api/src/api-v1/auth/github-init.ts b/apps/api/src/api-v1/auth/github-init.ts new file mode 100644 index 00000000..30ca56d1 --- /dev/null +++ b/apps/api/src/api-v1/auth/github-init.ts @@ -0,0 +1,71 @@ +import type { DefaultHonoEnv } from '@agentic/platform-hono' +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +import { env } from '@/lib/env' +import { + openapiAuthenticatedSecuritySchemas, + openapiErrorResponse404, + openapiErrorResponses +} from '@/lib/openapi-utils' + +import { authStorage } from './utils' + +const route = createRoute({ + description: 'Starts a GitHub OAuth flow.', + tags: ['auth'], + operationId: 'initGitHubOAuthFlow', + method: 'get', + path: 'auth/github/init', + security: openapiAuthenticatedSecuritySchemas, + request: { + query: z + .object({ + redirect_uri: z.string(), + client_id: z.string().optional(), + scope: z.string().optional() + }) + .passthrough() + }, + responses: { + 302: { + description: 'Redirected to GitHub' + }, + ...openapiErrorResponses, + ...openapiErrorResponse404 + } +}) + +export function registerV1AuthGitHubOAuthInitFlow( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const logger = c.get('logger') + const { + client_id: clientId = env.GITHUB_CLIENT_ID, + scope = 'user:email', + redirect_uri: redirectUri + } = c.req.query() + + const state = crypto.randomUUID() + + // TODO: unique identifier + await authStorage.set(['github', state, 'redirectUri'], { redirectUri }) + + const publicRedirectUri = `${env.apiBaseUrl}/v1/auth/github/callback` + + const url = new URL('https://github.com/login/oauth/authorize') + url.searchParams.append('client_id', clientId) + url.searchParams.append('scope', scope) + url.searchParams.append('state', state) + url.searchParams.append('redirect_uri', publicRedirectUri) + + logger.info('Redirecting to GitHub', { + url: url.toString(), + clientId, + scope, + publicRedirectUri + }) + + return c.redirect(url.toString()) + }) +} diff --git a/apps/api/src/api-v1/auth/utils.ts b/apps/api/src/api-v1/auth/utils.ts new file mode 100644 index 00000000..c8926e21 --- /dev/null +++ b/apps/api/src/api-v1/auth/utils.ts @@ -0,0 +1,3 @@ +import { DrizzleAuthStorage } from '@/lib/drizzle-auth-storage' + +export const authStorage = DrizzleAuthStorage() diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 8cf4b9ac..a9aa2426 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -6,7 +6,9 @@ import type { AuthenticatedHonoEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils' -import { registerV1AuthExchangeOAuthCodeWithGitHub } from './auth/github' +import { registerV1AuthGitHubOAuthCallback } from './auth/github-callback' +import { registerV1AuthGitHubOAuthExchange } from './auth/github-exchange' +import { registerV1AuthGitHubOAuthInitFlow } from './auth/github-init' import { registerV1AuthSignInWithPassword } from './auth/sign-in-with-password' import { registerV1AuthSignUpWithPassword } from './auth/sign-up-with-password' import { registerV1AdminConsumersActivateConsumer } from './consumers/admin-activate-consumer' @@ -78,7 +80,9 @@ registerHealthCheck(publicRouter) // Auth registerV1AuthSignInWithPassword(publicRouter) registerV1AuthSignUpWithPassword(publicRouter) -registerV1AuthExchangeOAuthCodeWithGitHub(publicRouter) +registerV1AuthGitHubOAuthExchange(publicRouter) +registerV1AuthGitHubOAuthInitFlow(publicRouter) +registerV1AuthGitHubOAuthCallback(publicRouter) // Users registerV1UsersGetUser(privateRouter) diff --git a/apps/api/src/lib/drizzle-auth-storage-temp b/apps/api/src/lib/drizzle-auth-storage.ts similarity index 100% rename from apps/api/src/lib/drizzle-auth-storage-temp rename to apps/api/src/lib/drizzle-auth-storage.ts diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index de8f664e..1660229d 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -42,11 +42,15 @@ export function parseEnv(inputEnv: Record) { ) const isStripeLive = env.STRIPE_SECRET_KEY.startsWith('sk_live_') + const apiBaseUrl = baseEnv.isProd + ? 'https://api.agentic.so' + : 'http://localhost:3001' return { ...baseEnv, ...env, - isStripeLive + isStripeLive, + apiBaseUrl } } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3dcb9689..93c9cb42 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -10,7 +10,7 @@ import { env } from '@/lib/env' import * as middleware from '@/lib/middleware' import { initExitHooks } from './lib/exit-hooks' -import { registerOAuthRedirect } from './oauth-redirect' +// import { registerOAuthRedirect } from './oauth-redirect' export const app = new OpenAPIHono() @@ -32,7 +32,7 @@ app.use(middleware.accessLogger) app.use(middleware.responseTime) // TODO: top-level auth routes -registerOAuthRedirect(app) +// registerOAuthRedirect(app) // Mount all v1 API routes app.route('/v1', apiV1) diff --git a/packages/api-client/src/agentic-api-client.ts b/packages/api-client/src/agentic-api-client.ts index f4eb6e3f..77e4cc4b 100644 --- a/packages/api-client/src/agentic-api-client.ts +++ b/packages/api-client/src/agentic-api-client.ts @@ -212,12 +212,10 @@ export class AgenticApiClient { scope?: string clientId?: string }): Promise { - const publicRedirectUri = `${this.apiBaseUrl}/oauth/callback?${new URLSearchParams({ uri: redirectUri }).toString()}` - - const url = new URL('https://github.com/login/oauth/authorize') + const url = new URL(`${this.apiBaseUrl}/v1/auth/github/init`) url.searchParams.append('client_id', clientId) url.searchParams.append('scope', scope) - url.searchParams.append('redirect_uri', publicRedirectUri) + url.searchParams.append('redirect_uri', redirectUri) return url.toString() } @@ -226,7 +224,9 @@ export class AgenticApiClient { async exchangeOAuthCodeWithGitHub( json: OperationBody<'exchangeOAuthCodeWithGitHub'> ): Promise { - this._authSession = await this.ky.post('v1/auth/github', { json }).json() + this._authSession = await this.ky + .post('v1/auth/github/exchange', { json }) + .json() this.onUpdateAuth?.(this._authSession) return this._authSession diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index c0203375..92eba9ab 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -55,7 +55,7 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/auth/github": { + "/v1/auth/github/exchange": { parameters: { query?: never; header?: never; @@ -64,7 +64,7 @@ export interface paths { }; get?: never; put?: never; - /** @description Exchanges GitHub code for auth session. */ + /** @description Exchanges a GitHub OAuth code for an Agentic auth session. */ post: operations["exchangeOAuthCodeWithGitHub"]; delete?: never; options?: never; @@ -72,6 +72,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/auth/github/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Starts a GitHub OAuth flow. */ + get: operations["initGitHubOAuthFlow"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/users/{userId}": { parameters: { query?: never; @@ -1021,6 +1038,32 @@ export interface operations { 404: components["responses"]["404"]; }; }; + initGitHubOAuthFlow: { + parameters: { + query: { + redirect_uri: string; + client_id?: string; + scope?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirected to GitHub */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + }; + }; getUser: { parameters: { query?: never;