22// The five D1 device endpoints (design v0.3 §4.2, §15) through their real
33// route handlers and the real store on an isolated MySQL database: answer
44// shapes, the code map with its HTTP statuses, no-store on every answer,
5- // Retry-After on every 429 and 503, the verification URL (APP_URL only, no
5+ // Retry-After on every 429 and 503, the verification URL and entry links (APP_URL only, no
66// code), and a log probe that no token or pairing code reaches a log line.
77//
88// SEAT_DEVICE_DATABASE_URL=<disposable db> DATABASE_URL=<same>
@@ -160,7 +160,7 @@ describeMysql("seat device endpoints on MySQL", () => {
160160 async function authorize ( source = freshSource ( ) ) {
161161 const answer = await post ( "authorize" , DEVICE , source ) ;
162162 expect ( answer . status ) . toBe ( 200 ) ;
163- return { grant : answer . body as Grant & { verificationUrl : string } , source } ;
163+ return { grant : answer . body as Grant & { verificationUrl : string ; entryLinks : unknown } , source } ;
164164 }
165165
166166 // A claimed device of a fresh member, from a trusted source.
@@ -185,6 +185,8 @@ describeMysql("seat device endpoints on MySQL", () => {
185185 vi . stubEnv ( "HELM_SEAT_DEVICE_AUTH_ENABLED" , "true" ) ;
186186 vi . stubEnv ( "HELM_SEAT_DEVICE_TRUSTED_PROXY_HOPS" , "1" ) ;
187187 vi . stubEnv ( "APP_URL" , APP_ORIGIN ) ;
188+ // The activation page (and so its entry link) is off unless a test opens it.
189+ vi . stubEnv ( "HELM_AUTH_MEMBER_ACTIVATION_ENABLED" , undefined ) ;
188190 for ( const method of CONSOLE_METHODS ) {
189191 const original = console [ method ] . bind ( console ) ;
190192 vi . spyOn ( console , method ) . mockImplementation ( ( ...args : unknown [ ] ) => {
@@ -209,9 +211,11 @@ describeMysql("seat device endpoints on MySQL", () => {
209211 it ( "authorizes with a URL built from APP_URL only, never carrying the code, and keeps the trusted source" , async ( ) => {
210212 nextDay ( ) ;
211213 const { grant, source } = await authorize ( ) ;
212- expect ( Object . keys ( grant ) . sort ( ) ) . toEqual ( [ "expiresInSeconds" , "intervalSeconds" , "pairingCode" , "pollToken" , "requestId" , "verificationUrl" ] ) ;
214+ expect ( Object . keys ( grant ) . sort ( ) ) . toEqual ( [ "entryLinks" , " expiresInSeconds", "intervalSeconds" , "pairingCode" , "pollToken" , "requestId" , "verificationUrl" ] ) ;
213215 expect ( grant ) . toMatchObject ( { expiresInSeconds : 600 , intervalSeconds : 5 } ) ;
214216 expect ( grant . verificationUrl ) . toBe ( `${ APP_ORIGIN } /device` ) ;
217+ // post() sends a hostile Host, x-forwarded-host and an http forwarded proto.
218+ expect ( grant . entryLinks ) . toEqual ( { signIn : `${ APP_ORIGIN } /login` , activateMember : null , signUp : null } ) ;
215219 const url = new URL ( grant . verificationUrl ) ;
216220 expect ( url . search + url . hash ) . toBe ( "" ) ;
217221 expect ( grant . verificationUrl ) . not . toContain ( grant . pairingCode . replace ( "-" , "" ) ) ;
@@ -222,13 +226,29 @@ describeMysql("seat device endpoints on MySQL", () => {
222226 expect ( row . pollTokenHash ) . toBe ( hashSeatDeviceToken ( grant . pollToken ) ) ;
223227 } ) ;
224228
229+ it ( "offers the activation link only while the activation page is on, and takes no address from the client" , async ( ) => {
230+ nextDay ( ) ;
231+ vi . stubEnv ( "HELM_AUTH_MEMBER_ACTIVATION_ENABLED" , "true" ) ;
232+ try {
233+ const { grant } = await authorize ( ) ;
234+ expect ( grant . entryLinks ) . toEqual ( { signIn : `${ APP_ORIGIN } /login` , activateMember : `${ APP_ORIGIN } /activate-member` , signUp : null } ) ;
235+ } finally {
236+ vi . stubEnv ( "HELM_AUTH_MEMBER_ACTIVATION_ENABLED" , undefined ) ;
237+ }
238+ // A client cannot steer the links: any address field is an unknown field.
239+ for ( const extra of [ { entryLinks : { signUp : "https://phish.example" } } , { signUpUrl : "https://phish.example" } , { verificationUrl : "https://phish.example/device" } ] ) {
240+ expect ( only ( await post ( "authorize" , { ...DEVICE , ...extra } , freshSource ( ) ) ) ) . toEqual ( { status : 400 , body : errorOf ( "invalid_input" ) } ) ;
241+ }
242+ } ) ;
243+
225244 it ( "resolves the public origin before storing anything" , async ( ) => {
226245 nextDay ( ) ;
227246 const before = await db . seatDeviceAuthorization . count ( ) ;
228247 vi . stubEnv ( "NODE_ENV" , "production" ) ;
229248 vi . stubEnv ( "APP_URL" , "http://plain-http.example.test" ) ;
230249 try {
231- expect ( await post ( "authorize" , DEVICE , freshSource ( ) ) ) . toMatchObject ( { status : 503 , body : errorOf ( "temporarily_unavailable" ) } ) ;
250+ // Fail closed: no link of any kind leaves with a non-https origin.
251+ expect ( only ( await post ( "authorize" , DEVICE , freshSource ( ) ) ) ) . toEqual ( { status : 503 , body : errorOf ( "temporarily_unavailable" ) } ) ;
232252 } finally {
233253 vi . stubEnv ( "NODE_ENV" , "test" ) ;
234254 vi . stubEnv ( "APP_URL" , APP_ORIGIN ) ;
0 commit comments