Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c461a61c | |||
| 022086beb8 |
@@ -116,3 +116,24 @@ and implements label zoom behavior. Core preserves the supplied membership and
|
|||||||
label mode without inferring them from positions. Renderer-only `zIndex` is
|
label mode without inferring them from positions. Renderer-only `zIndex` is
|
||||||
ignored alongside transient state; restore frame layering in the frontend.
|
ignored alongside transient state; restore frame layering in the frontend.
|
||||||
This extends existing JSONB snapshots and requires no additional migration.
|
This extends existing JSONB snapshots and requires no additional migration.
|
||||||
|
|
||||||
|
### Browser session lifetime
|
||||||
|
|
||||||
|
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth
|
||||||
|
logins have a 30-day idle lifetime; authenticated requests atomically renew that
|
||||||
|
idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired,
|
||||||
|
revoked and wrong-project tokens are never revived. Logout removes the session.
|
||||||
|
|
||||||
|
Apply `db/004-session-lifetime.sql` before running this version. Existing sessions
|
||||||
|
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
|
||||||
|
the absolute 90-day cap starts at migration time because issuance was not recorded;
|
||||||
|
an unexpired legacy session gains the new idle lifetime on its next authenticated
|
||||||
|
request. New rows record their actual creation time and absolute deadline.
|
||||||
|
|
||||||
|
Login and exchange responses return `expires_in: 2592000`. Protected platform
|
||||||
|
responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining
|
||||||
|
seconds after renewal, shortened near the absolute deadline. The frontend proxy
|
||||||
|
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
|
||||||
|
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
|
||||||
|
session token to client JavaScript. CORS exposes the lifetime header alongside
|
||||||
|
ETag where applicable. The absolute deadline stays server-authoritative.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Existing sessions keep their current idle deadline: expired sessions stay expired.
|
||||||
|
-- Their original issuance time was not recorded, so the new absolute cap starts here.
|
||||||
|
ALTER TABLE project_sessions
|
||||||
|
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN absolute_expires_at timestamptz NOT NULL DEFAULT (now() + interval '90 days');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -17,7 +17,8 @@ psql <<'SQL'
|
|||||||
INSERT INTO project_accounts(project,id,google_subject,email)
|
INSERT INTO project_accounts(project,id,google_subject,email)
|
||||||
VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid');
|
VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid');
|
||||||
INSERT INTO project_sessions VALUES
|
INSERT INTO project_sessions VALUES
|
||||||
('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour');
|
('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'),
|
||||||
|
('expired-session','fixture','00000000-0000-0000-0000-000000000001',now()-interval '1 hour');
|
||||||
SQL
|
SQL
|
||||||
psql <<'SQLROLE'
|
psql <<'SQLROLE'
|
||||||
CREATE ROLE foundry_fixture LOGIN;
|
CREATE ROLE foundry_fixture LOGIN;
|
||||||
@@ -33,6 +34,12 @@ BEGIN
|
|||||||
JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id)
|
JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id)
|
||||||
WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google'
|
WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google'
|
||||||
) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF;
|
) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='expired-session' AND expires_at>now()) THEN
|
||||||
|
RAISE EXCEPTION 'Migration revived expired session';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='existing-session' AND absolute_expires_at>now()+interval '89 days') THEN
|
||||||
|
RAISE EXCEPTION 'Missing absolute session deadline';
|
||||||
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
BEGIN;
|
BEGIN;
|
||||||
INSERT INTO project_accounts(project,id,email) VALUES
|
INSERT INTO project_accounts(project,id,email) VALUES
|
||||||
|
|||||||
+14
-11
@@ -129,6 +129,7 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return reject(w, 403, "origin_denied")
|
return reject(w, 403, "origin_denied")
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", SessionExpiryHeader)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodOptions:
|
case r.Method == http.MethodOptions:
|
||||||
@@ -289,8 +290,8 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
|
|||||||
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
|
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
|
||||||
RETURNING account_id
|
RETURNING account_id
|
||||||
)
|
)
|
||||||
INSERT INTO project_sessions
|
INSERT INTO project_sessions(token_hash,project,account_id,expires_at)
|
||||||
SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant
|
SELECT $4, $2, account_id, now() + interval '30 days' FROM redeemed_grant
|
||||||
`, digest(code), project, digest(verifier), digest(session))
|
`, digest(code), project, digest(verifier), digest(session))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -302,7 +303,7 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
return reject(w, 401, "invalid_grant")
|
return reject(w, 401, "invalid_grant")
|
||||||
}
|
}
|
||||||
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,19 +320,21 @@ func (s *service) account(w http.ResponseWriter, r *http.Request, project string
|
|||||||
reply(w, 204, nil)
|
reply(w, 204, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var id string
|
session, err := AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
||||||
var email *string
|
if errors.Is(err, ErrInvalidSession) {
|
||||||
err := s.db.QueryRowContext(r.Context(), `
|
|
||||||
SELECT a.id::text, a.email FROM project_sessions s
|
|
||||||
JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id
|
|
||||||
WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now()
|
|
||||||
`, digest(token), project).Scan(&id, &email)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return reject(w, 401, "invalid_session")
|
return reject(w, 401, "invalid_session")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var email *string
|
||||||
|
err = s.db.QueryRowContext(r.Context(), "SELECT email FROM project_accounts WHERE project=$1 AND id=$2", project, session.AccountID).Scan(&email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
WriteSessionHeaders(w, session)
|
||||||
|
id := session.AccountID
|
||||||
|
|
||||||
reply(w, 200, payload{"project": project, "id": id, "email": email})
|
reply(w, 200, payload{"project": project, "id": id, "email": email})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, a
|
|||||||
return reject(w, 401, "invalid_credentials")
|
return reject(w, 401, "invalid_credentials")
|
||||||
}
|
}
|
||||||
session := token()
|
session := token()
|
||||||
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +205,13 @@ SELECT $1,id,$2,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session := token()
|
session := token()
|
||||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,47 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const SessionIdleSeconds = 30 * 24 * 60 * 60
|
||||||
|
const SessionExpiryHeader = "X-Foundry-Session-Expires-In"
|
||||||
|
|
||||||
var ErrInvalidSession = errors.New("invalid session")
|
var ErrInvalidSession = errors.New("invalid session")
|
||||||
|
|
||||||
// Authenticate resolves only an unexpired session belonging to this project.
|
type Session struct {
|
||||||
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
|
AccountID string
|
||||||
|
ExpiresIn int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateSession atomically validates and renews a live session, bounded by
|
||||||
|
// its absolute deadline. Expired, revoked and wrong-project tokens never renew.
|
||||||
|
func AuthenticateSession(ctx context.Context, db *sql.DB, project, authorization string) (Session, error) {
|
||||||
value, ok := strings.CutPrefix(authorization, "Bearer ")
|
value, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||||
if !ok || !tokenPattern.MatchString(value) {
|
if !ok || !tokenPattern.MatchString(value) {
|
||||||
return "", ErrInvalidSession
|
return Session{}, ErrInvalidSession
|
||||||
}
|
}
|
||||||
var id string
|
var session Session
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
SELECT account_id::text FROM project_sessions
|
UPDATE project_sessions
|
||||||
WHERE project=$1 AND token_hash=$2 AND expires_at>now()
|
SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at)
|
||||||
`, project, digest(value)).Scan(&id)
|
WHERE project=$1 AND token_hash=$2 AND expires_at>now() AND absolute_expires_at>now()
|
||||||
|
RETURNING account_id::text,GREATEST(0,FLOOR(EXTRACT(EPOCH FROM (expires_at-now()))))::bigint
|
||||||
|
`, project, digest(value)).Scan(&session.AccountID, &session.ExpiresIn)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", ErrInvalidSession
|
return Session{}, ErrInvalidSession
|
||||||
}
|
}
|
||||||
return id, err
|
return session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteSessionHeaders(w http.ResponseWriter, session Session) {
|
||||||
|
w.Header().Set(SessionExpiryHeader, strconv.FormatInt(session.ExpiresIn, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate preserves the existing API for clients that only need an account ID.
|
||||||
|
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
|
||||||
|
session, err := AuthenticateSession(ctx, db, project, authorization)
|
||||||
|
return session.AccountID, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "ETag")
|
w.Header().Set("Access-Control-Expose-Headers", "ETag, "+auth.SessionExpiryHeader)
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||||
@@ -77,11 +77,15 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
|||||||
reply(w, 204, nil)
|
reply(w, 204, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
session, err := auth.AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
||||||
if errors.Is(err, auth.ErrInvalidSession) {
|
if errors.Is(err, auth.ErrInvalidSession) {
|
||||||
reject(w, 401, "invalid_session")
|
reject(w, 401, "invalid_session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
account := session.AccountID
|
||||||
|
if err == nil {
|
||||||
|
auth.WriteSessionHeaders(w, session)
|
||||||
|
}
|
||||||
if err == nil && r.PathValue("workspace") != "" {
|
if err == nil && r.PathValue("workspace") != "" {
|
||||||
var id string
|
var id string
|
||||||
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
|
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
|
||||||
|
|||||||
Reference in New Issue
Block a user