55// callback refusing forged/incomplete redirects, the sealed-session cookie
66// actually authorizing the session API, and logout dropping the cookie.
77import { expect } from "@effect/vitest" ;
8- import { Effect , Encoding , Result , Schema } from "effect" ;
8+ import { Effect , Encoding , Option , Result , Schema } from "effect" ;
99
1010import { scenario } from "../src/scenario" ;
1111import { Api , Target } from "../src/services" ;
@@ -46,11 +46,10 @@ scenario(
4646 const decoded = decodeLoginState (
4747 Result . getOrElse ( Encoding . decodeBase64UrlString ( state ) , ( ) => "" ) ,
4848 ) ;
49- expect ( decoded . _tag , "the state decodes as our login-state envelope" ) . toBe ( "Some" ) ;
50- expect (
51- decoded . _tag === "Some" ? decoded . value . nonce : "" ,
52- "the state carries an unguessable CSRF nonce" ,
53- ) . toMatch ( / ^ [ 0 - 9 a - f ] { 64 } $ / ) ;
49+ expect ( Option . isSome ( decoded ) , "the state decodes as our login-state envelope" ) . toBe ( true ) ;
50+ expect ( Option . getOrThrow ( decoded ) . nonce , "the state carries an unguessable CSRF nonce" ) . toMatch (
51+ / ^ [ 0 - 9 a - f ] { 64 } $ / ,
52+ ) ;
5453 expect (
5554 authorizeUrl . searchParams . get ( "redirect_uri" ) ,
5655 "AuthKit is told to come back to this deployment's callback" ,
@@ -65,6 +64,31 @@ scenario(
6564 } ) ,
6665) ;
6766
67+ scenario (
68+ "Auth · login refuses return paths that normalize outside the allowed pages" ,
69+ { } ,
70+ Effect . gen ( function * ( ) {
71+ yield * Api ;
72+ const target = yield * Target ;
73+ for ( const returnTo of [
74+ "/\\evil.example" ,
75+ "/safe/../api/auth/me" ,
76+ "/safe/%2e%2e/api/auth/me" ,
77+ ] ) {
78+ const login = new URL ( "/api/auth/login" , target . baseUrl ) ;
79+ login . searchParams . set ( "returnTo" , returnTo ) ;
80+ const response = yield * Effect . promise ( ( ) => fetch ( login , { redirect : "manual" } ) ) ;
81+ expect ( response . status ) . toBe ( 302 ) ;
82+ const state = new URL ( response . headers . get ( "location" ) ?? "" ) . searchParams . get ( "state" ) ?? "" ;
83+ const decoded = decodeLoginState (
84+ Result . getOrElse ( Encoding . decodeBase64UrlString ( state ) , ( ) => "" ) ,
85+ ) ;
86+ expect ( Option . isSome ( decoded ) ) . toBe ( true ) ;
87+ expect ( Option . getOrThrow ( decoded ) . returnTo ) . toBeUndefined ( ) ;
88+ }
89+ } ) ,
90+ ) ;
91+
6892scenario (
6993 "Auth · the callback rejects forged or incomplete redirects without exchanging the code" ,
7094 { } ,
0 commit comments