mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 02:25:36 +08:00
fix(sso): hand the issuer /auth/callback so the code gets redeemed
The auth guard asked the issuer to come back to the page the visitor wanted. The issuer appends ?code= to whatever URL it is given, but only /auth/callback redeems a code — so it arrived on the target page, sat there unread, the guard found no session and bounced back for a fresh code. The browser ping-ponged between the two hosts until the user gave up. The guard now hands over /auth/callback and carries the wanted page in its redirect parameter, which is exactly what that page already expected. A spent code in the URL is dropped rather than carried along, so a stale link cannot turn into a redemption error one hop later. URL building moved into shared/utils/ssoHandoff.ts because the same mistake existed twice — the retry button on the callback page had it too — and because a redirect loop deserves a regression test that does not need a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
62
tests/shared/ssoHandoff.test.ts
Normal file
62
tests/shared/ssoHandoff.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildIssuerLoginUrl, stripSsoCode } from '~~/shared/utils/ssoHandoff'
|
||||
|
||||
const ISSUER = 'https://opensquawk.de'
|
||||
const ORIGIN = 'https://app.opensquawk.de'
|
||||
|
||||
function redirectParam(url: string): string {
|
||||
return new URL(url).searchParams.get('redirect') || ''
|
||||
}
|
||||
|
||||
describe('SSO handoff URL', () => {
|
||||
it('sends the issuer to /auth/callback, not to the page the user wanted', () => {
|
||||
// The redirect loop: the issuer appends ?code= to whatever it is handed,
|
||||
// and only /auth/callback redeems it. Handing it '/' left the code unread
|
||||
// and the guard bounced straight back for another one.
|
||||
const url = buildIssuerLoginUrl(ISSUER, ORIGIN, '/')
|
||||
const target = new URL(redirectParam(url))
|
||||
|
||||
assert.equal(target.origin, ORIGIN)
|
||||
assert.equal(target.pathname, '/auth/callback')
|
||||
})
|
||||
|
||||
it('carries the wanted page along so the callback can finish the trip', () => {
|
||||
const url = buildIssuerLoginUrl(ISSUER, ORIGIN, '/classroom?lesson=3')
|
||||
const target = new URL(redirectParam(url))
|
||||
|
||||
assert.equal(target.searchParams.get('redirect'), '/classroom?lesson=3')
|
||||
})
|
||||
|
||||
it('points at the issuer login page and encodes the target as one parameter', () => {
|
||||
const url = buildIssuerLoginUrl(ISSUER, ORIGIN, '/live-atc')
|
||||
|
||||
assert.ok(url.startsWith('https://opensquawk.de/login?redirect='))
|
||||
// Exactly one query parameter — an unencoded '?' in the value would split
|
||||
// the target into a second parameter and lose it.
|
||||
assert.deepEqual([...new URL(url).searchParams.keys()], ['redirect'])
|
||||
})
|
||||
|
||||
it('tolerates a trailing slash on the issuer', () => {
|
||||
const url = buildIssuerLoginUrl('https://opensquawk.de/', ORIGIN, '/')
|
||||
assert.ok(url.startsWith('https://opensquawk.de/login?'))
|
||||
})
|
||||
|
||||
it('does not carry a spent code back to the issuer', () => {
|
||||
const url = buildIssuerLoginUrl(ISSUER, ORIGIN, '/?code=already-used')
|
||||
const target = new URL(redirectParam(url))
|
||||
|
||||
assert.equal(target.searchParams.get('redirect'), '/')
|
||||
})
|
||||
|
||||
it('treats an empty target as the front page', () => {
|
||||
const target = new URL(redirectParam(buildIssuerLoginUrl(ISSUER, ORIGIN, '')))
|
||||
assert.equal(target.searchParams.get('redirect'), '/')
|
||||
})
|
||||
|
||||
it('leaves other query parameters alone when stripping the code', () => {
|
||||
assert.equal(stripSsoCode('/classroom?lesson=3&code=x', ORIGIN), '/classroom?lesson=3')
|
||||
assert.equal(stripSsoCode('/classroom?lesson=3', ORIGIN), '/classroom?lesson=3')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user