diff --git a/internal/core/client.go b/internal/core/client.go index d5a7ec3..eb16992 100644 --- a/internal/core/client.go +++ b/internal/core/client.go @@ -53,7 +53,9 @@ func (c *Client) AccessTokenType() op.AccessTokenType { return op.AccessTokenTyp func (c *Client) IDTokenLifetime() time.Duration { return 5 * time.Minute } func (c *Client) DevMode() bool { return c.Development } func (c *Client) RestrictAdditionalIdTokenScopes() func([]string) []string { - return func([]string) []string { return nil } + // Preserve the request scope so the provider reaches SetUserinfoFromRequest + // and can add request-bound claims such as the browser session ID. + return func(v []string) []string { return v } } func (c *Client) RestrictAdditionalAccessTokenScopes() func([]string) []string { return func(v []string) []string { return v } diff --git a/internal/httpapi/go_sdk_test.go b/internal/httpapi/go_sdk_test.go index 6e30993..9c47ada 100644 --- a/internal/httpapi/go_sdk_test.go +++ b/internal/httpapi/go_sdk_test.go @@ -59,6 +59,9 @@ func TestGoSDKLoginAndLinkContract(t *testing.T) { if result.Identity.Email != "alice@example.test" || result.Identity.Name != "Alice" { t.Fatal("profile mismatch") } + if result.Identity.SessionID == "" { + t.Fatal("Go SDK did not retain ID token sid") + } if _, err = client.FinishLogin(ctx, callback, fastcas.FinishOptions{BrowserBinding: binding}); err == nil { t.Fatal("callback replay accepted") } diff --git a/internal/httpapi/sdk_contract_test.go b/internal/httpapi/sdk_contract_test.go new file mode 100644 index 0000000..b6a80c9 --- /dev/null +++ b/internal/httpapi/sdk_contract_test.go @@ -0,0 +1,64 @@ +package httpapi_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func runSDKContract(t *testing.T, command string, args []string, directory string, extraEnv ...string) { + t.Helper() + if _, err := exec.LookPath(command); err != nil { + t.Skipf("requires %s and isolated SDK dependencies: %v", command, err) + } + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + _, b := setup(t) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, command, args...) + cmd.Dir = filepath.Join(root, directory) + cmd.Env = append(os.Environ(), append([]string{ + "FASTCAS_CONTRACT_ISSUER=" + b.issuer, + }, extraEnv...)...) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err = cmd.Run(); err != nil { + t.Fatalf("%s SDK contract: %v\n%s", directory, err, output.String()) + } + if !strings.Contains(output.String(), "sid contract passed") { + t.Fatalf("%s SDK contract did not report success:\n%s", directory, output.String()) + } +} + +func TestTypeScriptSDKRealProviderSIDContract(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(root, "sdk/typescript/dist/server.js")); err != nil { + t.Skipf("requires built TypeScript SDK: %v", err) + } + runSDKContract(t, "node", []string{"test/sid_contract.mjs"}, "sdk/typescript") +} + +func TestPythonSDKRealProviderSIDContract(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + pythonPath := filepath.Join(root, "sdk/python/src") + python := os.Getenv("FASTCAS_PYTHON_CONTRACT") + if python == "" { + python = filepath.Join(root, ".venv", "bin", "python") + } + runSDKContract(t, python, []string{"tests/sid_contract.py"}, "sdk/python", "PYTHONPATH="+pythonPath) +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index b4d87b7..76d7fc2 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -33,6 +33,15 @@ type browser struct { issuer string } +func newBrowser(t *testing.T, issuer string) browser { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return browser{t: t, http: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, issuer: issuer} +} + func (b browser) request(method, path string, form url.Values) (*http.Response, []byte) { b.t.Helper() var body io.Reader @@ -58,6 +67,30 @@ func (b browser) request(method, path string, form url.Values) (*http.Response, } return response, data } + +func (b browser) apiRequest(method, path string) (*http.Response, []byte) { + b.t.Helper() + req, err := http.NewRequest(method, b.issuer+path, nil) + if err != nil { + b.t.Fatal(err) + } + req.Header.Set("Origin", b.issuer) + for _, cookie := range b.http.Jar.Cookies(req.URL) { + if cookie.Name == "fastcas_csrf" { + req.Header.Set("X-CSRF-Token", cookie.Value) + } + } + response, err := b.http.Do(req) + if err != nil { + b.t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + b.t.Fatal(err) + } + return response, data +} func csrf(t *testing.T, body []byte) string { t.Helper() match := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindSubmatch(body) @@ -79,8 +112,6 @@ func setup(t *testing.T) (*core.Store, browser) { server.Config.Handler = handler server.Start() t.Cleanup(server.Close) - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} err = store.RegisterClient(context.Background(), core.Client{ID: "write", Name: "FastWrite", Development: true, Redirects: []string{"http://127.0.0.1:3003/callback"}, Scopes: []string{"openid", "profile", "email", "offline_access"}}, clientSecret) if err != nil { t.Fatal(err) @@ -89,7 +120,7 @@ func setup(t *testing.T) (*core.Store, browser) { if err != nil { t.Fatal(err) } - return store, browser{t, client, issuer} + return store, newBrowser(t, issuer) } func authorizeCode(t *testing.T, b browser) (string, string, string) { return authorizeCodeWithScopes(t, b, "openid profile email offline_access") @@ -173,6 +204,50 @@ func exchange(t *testing.T, b browser, values url.Values) (int, map[string]any) } return resp.StatusCode, data } + +func introspect(t *testing.T, b browser, token string) bool { + t.Helper() + req, err := http.NewRequest("POST", b.issuer+"/oauth/introspect", strings.NewReader(url.Values{"token": {token}}.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("write", clientSecret) + response, err := b.http.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var data struct { + Active bool `json:"active"` + } + if err = json.NewDecoder(response.Body).Decode(&data); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("introspection status %d", response.StatusCode) + } + return data.Active +} + +func signedSID(t *testing.T, store *core.Store, raw string) string { + t.Helper() + parsed, err := jwt.ParseSigned(raw, []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + t.Fatal(err) + } + var claims struct { + SID string `json:"sid"` + } + if err = parsed.Claims(&store.Keys.Private.PublicKey, &claims); err != nil { + t.Fatal(err) + } + if claims.SID == "" { + t.Fatal("ID token missing sid") + } + return claims.SID +} + func TestOIDCHTTPLoginRefreshAndReplay(t *testing.T) { store, b := setup(t) code, verifier, nonce := authorizeCode(t, b) @@ -191,6 +266,8 @@ func TestOIDCHTTPLoginRefreshAndReplay(t *testing.T) { Nonce string `json:"nonce"` Subject string `json:"sub"` SID string `json:"sid"` + Name string `json:"name"` + Email string `json:"email"` } if err = parsed.Claims(&store.Keys.Private.PublicKey, &claims); err != nil { t.Fatal(err) @@ -198,6 +275,12 @@ func TestOIDCHTTPLoginRefreshAndReplay(t *testing.T) { if claims.Issuer != b.issuer || claims.Nonce != nonce || claims.Subject == "" || len(claims.Audience) != 1 || claims.Audience[0] != "write" { t.Fatalf("invalid identity claims") } + if claims.SID == "" { + t.Fatal("authorization code ID token missing sid") + } + if claims.Name != "" || claims.Email != "" { + t.Fatalf("authorization code ID token expanded profile claims: name=%q email=%q", claims.Name, claims.Email) + } if status, _ = exchange(t, b, form); status == 200 { t.Fatal("authorization code replay accepted") } @@ -206,6 +289,9 @@ func TestOIDCHTTPLoginRefreshAndReplay(t *testing.T) { if status != 200 { t.Fatalf("refresh status %d: %v", status, rotated) } + if sid := signedSID(t, store, rotated["id_token"].(string)); sid != claims.SID { + t.Fatalf("refresh ID token sid %q differs from authorization code sid %q", sid, claims.SID) + } if status, _ = exchange(t, b, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}}); status == 200 { t.Fatal("refresh replay accepted") } @@ -213,6 +299,125 @@ func TestOIDCHTTPLoginRefreshAndReplay(t *testing.T) { t.Fatal("replayed family still refreshes") } } + +func TestOIDCHTTPLogoutRevokesOnlyTheSelectedSession(t *testing.T) { + localSessions := map[string]bool{} + var receivedLogoutSID string + var store *core.Store + receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read failed", http.StatusBadRequest) + return + } + logoutToken, err := url.ParseQuery(string(body)) + if err != nil || logoutToken.Get("logout_token") == "" { + http.Error(w, "logout token required", http.StatusBadRequest) + return + } + signed, err := jwt.ParseSigned(logoutToken.Get("logout_token"), []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + http.Error(w, "invalid logout token", http.StatusBadRequest) + return + } + var claims struct { + SID string `json:"sid"` + } + if err = signed.Claims(&store.Keys.Private.PublicKey, &claims); err != nil || claims.SID == "" { + http.Error(w, "invalid logout sid", http.StatusBadRequest) + return + } + receivedLogoutSID = claims.SID + if _, ok := localSessions[claims.SID]; !ok { + http.Error(w, "unknown local session", http.StatusBadRequest) + return + } + localSessions[claims.SID] = false + w.WriteHeader(http.StatusNoContent) + })) + defer receiver.Close() + var first browser + store, first = setup(t) + second := newBrowser(t, first.issuer) + if _, err := store.DB.Exec(context.Background(), `UPDATE applications SET config=config || jsonb_build_object('backchannel_logout_uri',$1::text) WHERE id='write'`, receiver.URL); err != nil { + t.Fatal(err) + } + var subject string + if err := store.DB.QueryRow(context.Background(), `SELECT id FROM identities WHERE email='alice@example.test'`).Scan(&subject); err != nil { + t.Fatal(err) + } + linkID := core.RandomToken() + if _, err := store.DB.Exec(context.Background(), `INSERT INTO link_intents(id,client_id,local_ref,idempotency_key,payload_hash,nonce,subject,state,expires_at) VALUES($1,'write','local-first',$2,'logout-test-hash','logout-test-nonce',$3,'active',now()+interval '1 hour')`, linkID, linkID, subject); err != nil { + t.Fatal(err) + } + if _, err := store.DB.Exec(context.Background(), `INSERT INTO account_links(id,client_id,local_ref,subject,state) VALUES($1,'write','local-first',$2,'active')`, linkID, subject); err != nil { + t.Fatal(err) + } + firstCode, firstVerifier, _ := authorizeCode(t, first) + status, firstTokens := exchange(t, first, url.Values{"grant_type": {"authorization_code"}, "code": {firstCode}, "redirect_uri": {"http://127.0.0.1:3003/callback"}, "code_verifier": {firstVerifier}}) + if status != 200 { + t.Fatalf("first token exchange status %d: %v", status, firstTokens) + } + firstSID := signedSID(t, store, firstTokens["id_token"].(string)) + + secondCode, secondVerifier, _ := authorizeCode(t, second) + status, secondTokens := exchange(t, second, url.Values{"grant_type": {"authorization_code"}, "code": {secondCode}, "redirect_uri": {"http://127.0.0.1:3003/callback"}, "code_verifier": {secondVerifier}}) + if status != 200 { + t.Fatalf("second token exchange status %d: %v", status, secondTokens) + } + secondSID := signedSID(t, store, secondTokens["id_token"].(string)) + if firstSID == secondSID { + t.Fatalf("independent browser sessions share sid %q", firstSID) + } + localSessions[firstSID], localSessions[secondSID] = true, true + firstAccess, firstRefresh := firstTokens["access_token"].(string), firstTokens["refresh_token"].(string) + secondAccess, secondRefresh := secondTokens["access_token"].(string), secondTokens["refresh_token"].(string) + if !introspect(t, first, firstAccess) || !introspect(t, second, secondAccess) { + t.Fatal("both access tokens must be active before logout") + } + var firstLive, secondLive bool + if err := store.DB.QueryRow(context.Background(), `SELECT revoked_at IS NULL FROM browser_sessions WHERE id=$1`, firstSID).Scan(&firstLive); err != nil || !firstLive { + t.Fatalf("first session missing before logout: %v", err) + } + if err := store.DB.QueryRow(context.Background(), `SELECT revoked_at IS NULL FROM browser_sessions WHERE id=$1`, secondSID).Scan(&secondLive); err != nil || !secondLive { + t.Fatalf("second session missing before logout: %v", err) + } + + response, body := first.apiRequest("POST", "/api/v1/me/sessions/"+url.PathEscape(firstSID)+"/revoke") + if response.StatusCode != http.StatusNoContent { + t.Fatalf("single-session logout status %d: %s", response.StatusCode, body) + } + if worked, err := store.DeliverEvent(context.Background(), first.issuer); err != nil || !worked { + t.Fatalf("backchannel logout delivery failed: worked=%v err=%v", worked, err) + } + if err := store.DB.QueryRow(context.Background(), `SELECT revoked_at IS NULL FROM browser_sessions WHERE id=$1`, firstSID).Scan(&firstLive); err != nil || firstLive { + t.Fatal("selected session survived logout") + } + if err := store.DB.QueryRow(context.Background(), `SELECT revoked_at IS NULL FROM browser_sessions WHERE id=$1`, secondSID).Scan(&secondLive); err != nil || !secondLive { + t.Fatalf("unselected session was revoked: %v", err) + } + if introspect(t, first, firstAccess) { + t.Fatal("selected access token survived logout") + } + if !introspect(t, second, secondAccess) { + t.Fatal("unselected access token was revoked") + } + if status, _ := exchange(t, first, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {firstRefresh}}); status == http.StatusOK { + t.Fatal("selected refresh token survived logout") + } + if status, _ := exchange(t, second, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {secondRefresh}}); status != http.StatusOK { + t.Fatalf("unselected refresh token was revoked: %d", status) + } + if receivedLogoutSID != firstSID || localSessions[firstSID] || !localSessions[secondSID] { + t.Fatalf("backchannel logout was not session-scoped: received=%q local=%v", receivedLogoutSID, localSessions) + } + if response, _ = first.request("GET", "/api/v1/me", nil); response.StatusCode != http.StatusUnauthorized { + t.Fatalf("logged-out browser remained authenticated: %d", response.StatusCode) + } + if response, body = second.request("GET", "/api/v1/me", nil); response.StatusCode != http.StatusOK { + t.Fatalf("unselected browser was logged out: %d %s", response.StatusCode, body) + } +} func TestWrongPKCEAndCrossOriginLoginRejected(t *testing.T) { store, b := setup(t) code, _, _ := authorizeCode(t, b) diff --git a/sdk/python/tests/contract.py b/sdk/python/tests/contract.py index 5a8ff51..b239651 100644 --- a/sdk/python/tests/contract.py +++ b/sdk/python/tests/contract.py @@ -53,6 +53,7 @@ def complete(url): result = sdk.finish_login(callback, binding) assert result["identity"]["email"] == "alice@example.test" assert result["identity"]["name"] == "Alice" + assert result["identity"]["sid"], "Python SDK did not retain ID token sid" rejected(lambda: sdk.finish_login(callback, binding), "transaction_invalid") sdk.verify_access_token(result["tokens"]["access_token"], "write") assert sdk.introspect_token(result["tokens"]["access_token"])["active"] is True diff --git a/sdk/python/tests/sid_contract.py b/sdk/python/tests/sid_contract.py new file mode 100644 index 0000000..23a6fd7 --- /dev/null +++ b/sdk/python/tests/sid_contract.py @@ -0,0 +1,56 @@ +import os +import re +import secrets +from urllib.parse import urljoin, urlparse, parse_qs + +import httpx + +from fastcas import Configuration, FastCAS +from fastcas.store import TransactionStore + + +class MemoryTransactions(TransactionStore): + def __init__(self): + self.values = {} + + def put(self, transaction): + self.values[transaction["state"]] = transaction + + def take(self, state): + return self.values.pop(state, None) + + +issuer = os.environ["FASTCAS_CONTRACT_ISSUER"] + +with httpx.Client(follow_redirects=False) as browser: + sdk = FastCAS(Configuration(issuer=issuer, client_id="write", client_secret="integration-client-secret-32-characters-long", redirect_uri="http://127.0.0.1:3003/callback", allow_loopback_http=True), MemoryTransactions()) + + def complete(url): + form = None + for _ in range(10): + response = browser.get(url) if form is None else browser.post(url, data=form, headers={"Origin": issuer}) + if response.status_code in (302, 303): + url = urljoin(issuer, response.headers["location"]) + if urlparse(url).netloc != urlparse(issuer).netloc: + return url + form = None + continue + assert response.status_code == 200 + body = response.text + csrf = re.search(r'name="csrf" value="([^"]+)"', body) + assert csrf, "Authorization form missing CSRF" + form = dict(csrf=csrf[1], auth_request_id=parse_qs(urlparse(url).query)["auth_request_id"][0]) + if 'name="password"' in body: + form.update(email="alice@example.test", password="correct horse battery staple", action="login") + else: + assert 'value="approve"' in body + form["action"] = "approve" + url = issuer + "/login" + raise AssertionError("Authorization redirect loop") + + binding = secrets.token_urlsafe(32) + callback = complete(sdk.begin_login(binding, scopes=["openid", "profile", "email", "offline_access"])) + login = sdk.finish_login(callback, binding) + assert login["identity"]["sid"], "Python SDK did not retain ID token sid" + +print("FastCAS Python sid contract passed") diff --git a/sdk/typescript/test/contract.mjs b/sdk/typescript/test/contract.mjs index a5c7d57..f84ff5d 100644 --- a/sdk/typescript/test/contract.mjs +++ b/sdk/typescript/test/contract.mjs @@ -45,6 +45,7 @@ const login = await cas.finishLogin(callback, { browserBinding: binding }); assert.equal(login.identity.email, "alice@example.test"); assert.equal(login.identity.name, "Alice"); assert.ok(login.identity.subject); +assert.ok(login.identity.sessionId, "TypeScript SDK did not retain ID token sid"); await assert.rejects(cas.finishLogin(callback, { browserBinding: binding }), { code: "transaction_invalid" }); await assert.rejects(cas.resolveLink(login.identity.subject), { status: 401 }); await cas.verifyAccessToken(login.tokens.access_token, "write"); @@ -59,7 +60,7 @@ assert.equal(active.state, "active"); assert.equal((await cas.resolveLink(login.identity.subject)).local_account_ref, "original-local-user"); const { csrf } = await (await browser(issuer + "/api/v1/me")).json(); const consent = await fetch(issuer + "/api/v1/me/delegations", { method: "POST", redirect: "manual", headers: { "content-type": "application/json", origin: issuer, "x-csrf-token": csrf, cookie: [...jar].map(([key, value]) => `${key}=${value}`).join("; ") }, body: JSON.stringify({ caller_client: "write", target_client: "research", resource: "research-api", scope: "research:read", active: true }) }); -assert.equal(consent.status, 204); +assert.equal(consent.status, 204, await consent.text()); const delegated = await cas.exchangeToken(login.tokens.access_token, "research-api", ["research:read"]); assert.equal(delegated.issued_token_type, "urn:ietf:params:oauth:token-type:access_token"); assert.equal(delegated.refresh_token, undefined); diff --git a/sdk/typescript/test/sid_contract.mjs b/sdk/typescript/test/sid_contract.mjs new file mode 100644 index 0000000..7b850d1 --- /dev/null +++ b/sdk/typescript/test/sid_contract.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { FastCAS } from "../dist/server.js"; + +const issuer = process.env.FASTCAS_CONTRACT_ISSUER; +if (!issuer) throw new Error("FASTCAS_CONTRACT_ISSUER required"); +const transactions = new Map(); +const store = { async put(value) { transactions.set(value.state, value); }, async take(state) { const value = transactions.get(state); transactions.delete(state); return value; } }; +const cas = new FastCAS({ issuer, clientId: "write", clientSecret: "integration-client-secret-32-characters-long", redirectUri: "http://127.0.0.1:3003/callback", allowLoopbackHTTP: true }, store); +const jar = new Map(); + +async function browser(url, form) { + const response = await fetch(url, { redirect: "manual", method: form ? "POST" : "GET", headers: { cookie: [...jar].map(([key, value]) => `${key}=${value}`).join("; "), ...(form ? { "content-type": "application/x-www-form-urlencoded", origin: issuer } : {}) }, body: form ? new URLSearchParams(form) : undefined }); + for (const cookie of response.headers.getSetCookie()) { const [entry] = cookie.split(";"); const at = entry.indexOf("="); jar.set(entry.slice(0, at), entry.slice(at + 1)); } + return response; +} + +async function complete(url) { + let destination = new URL(url), form; + for (let step = 0; step < 10; step++) { + const response = await browser(destination, form); + if (response.status === 302 || response.status === 303) { + destination = new URL(response.headers.get("location"), issuer); + if (destination.origin !== new URL(issuer).origin) return destination; + form = undefined; + continue; + } + assert.equal(response.status, 200); + const body = await response.text(); + const csrf = body.match(/name="csrf" value="([^"]+)"/); + assert.ok(csrf, "Authorization form missing CSRF"); + form = { csrf: csrf[1], auth_request_id: destination.searchParams.get("auth_request_id") }; + if (body.includes('name="password"')) Object.assign(form, { email: "alice@example.test", password: "correct horse battery staple", action: "login" }); + else { assert.ok(body.includes('value="approve"')); form.action = "approve"; } + destination = new URL("/login", issuer); + } + throw new Error("Authorization redirect loop"); +} + +const binding = "test-browser-binding-long-enough-for-sid-contract"; +const callback = await complete(await cas.beginLogin({ browserBinding: binding, scopes: ["openid", "profile", "email", "offline_access"] })); +const login = await cas.finishLogin(callback, { browserBinding: binding }); +assert.ok(login.identity.sessionId, "TypeScript SDK did not retain ID token sid"); +console.log("FastCAS TypeScript sid contract passed");