1+ /**
2+ * POST /api/auth/login
3+ *
4+ * Initiates an OAuth login flow. Returns a redirect URL the client should open.
5+ *
6+ * Two modes:
7+ * 1. Web (default): reads { provider, redirectTo } from request body.
8+ * Stores CSRF state in encrypted cookie, uses siteUrl as redirect base.
9+ * 2. CLI: reads provider, redirect_uri, state from query params.
10+ * redirect_uri must be a localhost callback (port 9876-9899).
11+ * State is caller-supplied (CLI manages its own CSRF).
12+ */
13+
14+ /** Validates that a redirect URI is an allowed CLI localhost callback. */
15+ function isAllowedCliRedirectUri ( uri : string ) : boolean {
16+ try {
17+ const parsed = new URL ( uri )
18+ if ( parsed . protocol !== 'http:' ) return false
19+ if ( parsed . hostname !== '127.0.0.1' && parsed . hostname !== 'localhost' ) return false
20+ const port = Number ( parsed . port )
21+ if ( port < 9876 || port > 9899 ) return false
22+ if ( parsed . pathname !== '/callback' ) return false
23+ return true
24+ }
25+ catch {
26+ return false
27+ }
28+ }
29+
130export default defineEventHandler ( async ( event ) => {
231 // Rate limit: 10 login requests per minute per IP
332 const ip = getClientIp ( event )
433 const rateCheck = await checkRateLimit ( `auth-login:${ ip } ` , 10 , 60_000 )
534 if ( ! rateCheck . allowed )
635 throw createError ( { statusCode : 429 , message : errorMessage ( 'auth.rate_limited' ) } )
736
8- const body = await readBody < { provider : 'github' | 'google' , redirectTo ?: string } > ( event )
37+ const query = getQuery ( event ) as { provider ?: string , redirect_uri ?: string , state ?: string }
38+ const isCli = ! ! ( query . provider && query . redirect_uri )
39+
40+ let provider : string
41+ let redirectTo : string
42+
43+ if ( isCli ) {
44+ // ── CLI flow ──
45+ provider = query . provider !
946
10- if ( ! body . provider || ! [ 'github' , 'google' ] . includes ( body . provider ) ) {
47+ if ( ! isAllowedCliRedirectUri ( query . redirect_uri ! ) ) {
48+ throw createError ( {
49+ statusCode : 400 ,
50+ message : 'redirect_uri must be http://127.0.0.1:{9876-9899}/callback' ,
51+ } )
52+ }
53+
54+ // CLI supplies the full redirect URI — pass it directly to the provider
55+ redirectTo = query . redirect_uri !
56+ }
57+ else {
58+ // ── Web flow (existing) ──
59+ const body = await readBody < { provider : string , redirectTo ?: string } > ( event )
60+ provider = body . provider
61+ redirectTo = body . redirectTo || '/auth/callback'
62+ }
63+
64+ if ( ! provider || ! [ 'github' , 'google' ] . includes ( provider ) ) {
1165 throw createError ( {
1266 statusCode : 400 ,
1367 message : errorMessage ( 'auth.invalid_provider' ) ,
@@ -16,11 +70,16 @@ export default defineEventHandler(async (event) => {
1670
1771 const authProvider = useAuthProvider ( )
1872 const result = await authProvider . getOAuthRedirectUrl (
19- body . provider ,
20- body . redirectTo || '/auth/callback' ,
73+ provider as 'github' | 'google' ,
74+ redirectTo ,
2175 )
2276
23- // Store provider-generated state in encrypted cookie for validation on callback
77+ if ( isCli ) {
78+ // CLI manages its own state — don't set cookie
79+ return { url : result . url , state : query . state || result . state }
80+ }
81+
82+ // Web: store provider-generated state in encrypted cookie for validation on callback
2483 if ( result . state ) {
2584 await setAuthState ( event , result . state )
2685 }
0 commit comments