152 lines
5.7 KiB
Go
152 lines
5.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
func sessionFixture(t *testing.T) (*sql.DB, string, string) {
|
|
t.Helper()
|
|
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("requires isolated integration database")
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := "session-" + token()[:12]
|
|
var account string
|
|
if err = db.QueryRow("INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project); db.Close() })
|
|
return db, project, account
|
|
}
|
|
|
|
func TestSessionIdleAndAbsoluteExpiry(t *testing.T) {
|
|
db, project, account := sessionFixture(t)
|
|
for _, test := range []struct {
|
|
name, idle, absolute string
|
|
valid bool
|
|
}{
|
|
{"active", "1 hour", "90 days", true},
|
|
{"idle-expired", "-1 second", "90 days", false},
|
|
{"absolute-cap", "1 hour", "2 days", true},
|
|
{"absolute-expired", "1 hour", "-1 second", false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
value := token()
|
|
_, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+$4::interval,now()+$5::interval)", digest(value), project, account, test.idle, test.absolute)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var before time.Time
|
|
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
|
|
if !test.valid {
|
|
if err != ErrInvalidSession {
|
|
t.Fatalf("expired session accepted: %v", err)
|
|
}
|
|
var after time.Time
|
|
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&after); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !after.Equal(before) {
|
|
t.Fatal("expired session revived")
|
|
}
|
|
return
|
|
}
|
|
if err != nil || session.AccountID != account || session.ExpiresIn <= 0 || session.ExpiresIn > SessionIdleSeconds {
|
|
t.Fatalf("invalid renewal result: %+v %v", session, err)
|
|
}
|
|
var expiry, cap time.Time
|
|
if err := db.QueryRow("SELECT expires_at,absolute_expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&expiry, &cap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if expiry.After(cap) || (test.name == "absolute-cap" && !expiry.Equal(cap)) {
|
|
t.Fatal("absolute cap not enforced")
|
|
}
|
|
if test.name == "active" && session.ExpiresIn < SessionIdleSeconds-2 {
|
|
t.Fatal("active session did not receive 30 idle days")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionIsolationRevocationAndConcurrentRenewal(t *testing.T) {
|
|
db, project, account := sessionFixture(t)
|
|
value := token()
|
|
if _, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+interval '1 hour',now()+interval '2 days')", digest(value), project, account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, input := range []struct{ project, authorization string }{{"other", "Bearer " + value}, {project, "Bearer bad-token"}, {project, ""}} {
|
|
if _, err := AuthenticateSession(context.Background(), db, input.project, input.authorization); err != ErrInvalidSession {
|
|
t.Fatalf("invalid or cross-project token accepted: %v", err)
|
|
}
|
|
}
|
|
var group sync.WaitGroup
|
|
for range 4 {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
|
|
if err != nil || session.ExpiresIn > 2*86400 {
|
|
t.Errorf("concurrent renewal escaped cap: %+v %v", session, err)
|
|
}
|
|
}()
|
|
}
|
|
group.Wait()
|
|
if _, err := db.Exec("DELETE FROM project_sessions WHERE token_hash=$1", digest(value)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value); err != ErrInvalidSession {
|
|
t.Fatal("revoked session renewed")
|
|
}
|
|
}
|
|
|
|
func TestOAuthExchangeIssuesLongLivedSessionAndMeReturnsRemainingLifetime(t *testing.T) {
|
|
db, project, account := sessionFixture(t)
|
|
code, verifier := token(), token()
|
|
_, err := db.Exec("INSERT INTO login_grants(token_hash,project,account_id,frontend_challenge,expires_at) VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(code), project, account, digest(verifier))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &service{db: db}
|
|
r := httptest.NewRequest("POST", "/exchange", nil)
|
|
r.Header.Set("X-Foundry-Code", code)
|
|
r.Header.Set("X-Foundry-Verifier", verifier)
|
|
out := httptest.NewRecorder()
|
|
if err = s.exchange(out, r, project); err != nil || out.Code != 200 {
|
|
t.Fatalf("exchange: %d %v", out.Code, err)
|
|
}
|
|
var result struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err = json.Unmarshal(out.Body.Bytes(), &result); err != nil || result.ExpiresIn != SessionIdleSeconds {
|
|
t.Fatal("incorrect login lifetime")
|
|
}
|
|
var idle, absolute float64
|
|
if err = db.QueryRow("SELECT EXTRACT(EPOCH FROM(expires_at-created_at)),EXTRACT(EPOCH FROM(absolute_expires_at-created_at)) FROM project_sessions WHERE token_hash=$1", digest(result.AccessToken)).Scan(&idle, &absolute); err != nil || idle < SessionIdleSeconds-2 || absolute != 90*86400 {
|
|
t.Fatalf("wrong session deadlines: %f %f %v", idle, absolute, err)
|
|
}
|
|
r = httptest.NewRequest("GET", "/me", strings.NewReader(""))
|
|
r.Header.Set("Authorization", "Bearer "+result.AccessToken)
|
|
out = httptest.NewRecorder()
|
|
if err = s.account(out, r, project, false); err != nil || out.Header().Get(SessionExpiryHeader) == "" {
|
|
t.Fatal("missing renewal header")
|
|
}
|
|
}
|