Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
- name: Conformance
env:
SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api
# This adapter ships the content-type gate, so hold it to the pending
# contract (seamless-cli verify/harness/lib/pendingContracts.ts).
SEAMLESS_VERIFY_CONTRACTS: content-type-gate
run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:8080
- name: Reference app log
if: always()
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ The module targets Go 1.22. Do not use standard library APIs newer than that (CI
- `adapter.go`: `Adapter`, the request pipeline, the upstream call, writing results.
- `routes.go`: manifest routes, credential resolution (with silent refresh), session
verification, what a cookie-transport body may contain.
- `refresh.go`: refresh sharing (one result per refresh token for 5 seconds), `POST /refresh`,
- `refresh.go`: refresh sharing (one rotation per refresh token while it is in flight; for cookie transport only, a
successful rotation is also kept for 5 seconds for a tab replaying the same cookie, never for bearer), `POST /refresh`,
logout.
- `manifest.go`: parsing, matching, the live and embedded manifest.
- `guard.go`: `RequireAuth`, `Authenticate`.
Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ both are present. It reads the `Cookie` header itself, so it works on any route.
refresh: the auth routes refresh a browser session silently, and a bearer client calls
`POST /auth/refresh` itself. `Authenticate` does the same check without answering.

Tabs holding one refresh cookie share its rotation: requests that arrive while it runs wait
for it, and a request presenting the same refresh cookie up to 5 seconds after it succeeded gets
the same session without another call. A bearer refresh token is shared only while its rotation
runs; presented again afterwards it goes to the auth API, which treats it as reuse.

A session cookie is signed with `CookieSecret`, but the auth API access token inside it is
verified too, the same way as a bearer token: RS256 against the API's JWKS, with the expected
issuer and audience, in date, of type `access`, and for the cookie's subject. `User.Roles` (and
Expand Down Expand Up @@ -132,20 +137,29 @@ new access cookie no longer carries it. A bearer client holds its own refresh to
`POST /auth/refresh` after enrolling.

A request `RequireAuth` admits on the session cookie also gets the auth routes' cross-site
check: while cookies are `SameSite=None`, a state-changing request from another site answers
403 `cross_site_request_blocked`. That is `Sec-Fetch-Site: cross-site`; or, with
`AllowedOrigins` set, `Sec-Fetch-Site: same-site` (which also covers a sibling subdomain) from an
check: whatever `CookieSameSite` is, a state-changing request from another site answers 403
`cross_site_request_blocked` (a sibling subdomain is same-site, so even `SameSite=Lax` cookies
ride along on its form POST). That is `Sec-Fetch-Site: cross-site`; or an `Origin` of `null`; or,
with `AllowedOrigins` set, `Sec-Fetch-Site: same-site` (which also covers a sibling subdomain) from an
`Origin` that is missing or not on the list; or, from a browser that sends no `Sec-Fetch-Site`,
an `Origin` outside `AllowedOrigins`. Without `AllowedOrigins`, a same-site request passes. A
bearer token is never attached by a browser, so it is not checked. `Authenticate` makes no such
non-nil empty `AllowedOrigins` is set, and allows no origin. A bearer token is never attached by a browser, so it is not checked. `Authenticate` makes no such
check.

### Request bodies

The auth routes forward a request body to the auth API as JSON, so a body must be sent as
`application/json` (or a `+json` type). Anything else answers 415 `unsupported_media_type`: a
cross-site form can post a `text/plain` body shaped like JSON with no CORS preflight, and read as
JSON it would be a sign-in the user never made. The client SDKs already send JSON.
JSON it would be a sign-in the user never made.

A state-changing request (anything but `GET`, `HEAD` and `OPTIONS`) to the auth routes must
declare a JSON `Content-Type` or carry the `X-Seamless-Auth-Transport` header (any value, for
example `cookie`) even when it has no body. Either one makes a cross-origin browser request send a
CORS preflight, which a form cannot. Without one it answers 415 `unsupported_media_type`.
`POST /auth/refresh`, `DELETE /auth/logout` and `DELETE /auth/logout/all` are exempt for now,
because current clients send them with no body and neither header; they still get the
cross-site check.

## Verify-only mode (resource servers)

Expand Down Expand Up @@ -181,7 +195,7 @@ guard but never a role or step-up guard. `Authenticate` does the same check with

The keys come from `<Issuer>/.well-known/openid-configuration`, whose `issuer` must equal
`Issuer` exactly and whose `jwks_uri` must be https when the issuer is. Set `JWKSURI` to skip
discovery. If discovery or the key set cannot be fetched, the request is refused with 401
discovery; it too must be https when the issuer is, or `NewVerifier` returns an error. If discovery or the key set cannot be fetched, the request is refused with 401
(discovery is retried at most every 5 seconds); it never answers 500.

A token is accepted only when it is RS256 with `typ` `at+jwt`, a `kid` the key set publishes for
Expand Down Expand Up @@ -236,7 +250,7 @@ g.GET("/orders", wrap(rs.RequireToken), wrap(rs.RequireScope("orders:read")), li
| `CookieDomain` | none | Cookie `Domain` |
| `InsecureCookies` | `false` | Drops `Secure`, for local development over HTTP. Also lets placeholder secrets through, with a warning |
| `CookieSameSite` | `None`, or `Lax` with `InsecureCookies` | Cookie `SameSite` |
| `AllowedOrigins` | none | The only cross-origin and same-site callers allowed to change state while cookies are `SameSite=None` |
| `AllowedOrigins` | none (nil) | The only cross-origin and same-site callers allowed to change state, whatever `CookieSameSite` is. An empty non-nil list allows none |
| `AccessCookieName`, `RefreshCookieName` | `seamless-access`, `seamless-refresh` | Session cookie names |
| `RegistrationCookieName`, `PreAuthCookieName` | `seamless-ephemeral` | Sign-in flow cookie names. Logout clears both |
| `Deliver` | none | Sends OTP codes and magic links through your own transports |
Expand Down
10 changes: 7 additions & 3 deletions adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,19 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request) {
a.write(w, res, false)
return
}
path := r.URL.EscapedPath()
if res, refused := a.checkContentType(r, path); refused {
a.write(w, res, false)
return
}

bearer := strings.EqualFold(r.Header.Get(transportHeader), "bearer")
path := r.URL.EscapedPath()

var res result
switch {
case r.Method == http.MethodPost && strings.EqualFold(path, "/refresh"):
case isRefreshRoute(r.Method, path):
res = a.refreshRoute(r, bearer)
case r.Method == http.MethodDelete && (strings.EqualFold(path, "/logout") || strings.EqualFold(path, "/logout/all")):
case isLogoutRoute(r.Method, path):
res = a.logoutRoute(r, bearer, path)
default:
match, ok := a.manifest.get(r.Context()).match(r.Method, path)
Expand Down
4 changes: 4 additions & 0 deletions adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync"
"sync/atomic"
"testing"
"time"
)

func TestLoginStoresThePreAuthTokenAndPicksTheBody(t *testing.T) {
Expand Down Expand Up @@ -101,8 +102,11 @@ func TestBearerTransportReturnsTheWholeBodyAndNoCookies(t *testing.T) {
func TestAMissingAccessCookieIsRestoredFromTheRefreshCookieOnce(t *testing.T) {
api := newFakeAPI(t)
var refreshes atomic.Int32
// Held open so every tab arrives while the rotation is in flight, which is
// the only time it is shared.
api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) {
refreshes.Add(1)
time.Sleep(300 * time.Millisecond)
writeJSON(w, 200, session(t, api, "u1"))
})
api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}}))
Expand Down
4 changes: 3 additions & 1 deletion helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ func do(t *testing.T, a *Adapter, method, target string, body string, setup ...f
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, reader)
if body != "" {
// As the client sends it, even with no body: an unsafe request needs a JSON
// content type or the transport header. Remove it with withoutHeader.
if body != "" || isUnsafeMethod(method) {
req.Header.Set("Content-Type", "application/json")
}
for _, s := range setup {
Expand Down
4 changes: 3 additions & 1 deletion jwks.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ type jwk struct {
Alg json.RawMessage `json:"alg"`
}

// fetch runs detached from the request that asks for it: a failed fetch holds
// off every caller for a while, so one caller going away must not fail it.
func (c *jwksCache) fetch(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, jwksFetchTimeout)
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jwksFetchTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
Expand Down
8 changes: 5 additions & 3 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ type Options struct {
InsecureCookies bool
// CookieSameSite defaults to None for secure cookies and Lax otherwise.
CookieSameSite http.SameSite
// AllowedOrigins, when set, is the only cross-origin callers allowed to make
// state-changing requests while cookies are SameSite=None. A request marked
// Sec-Fetch-Site: same-site must then carry an Origin on the list as well.
// AllowedOrigins, when set, is the only origins allowed to make
// state-changing requests, whatever CookieSameSite is. A request marked
// Sec-Fetch-Site: same-site, or one without Sec-Fetch-Site that carries an
// Origin, must then carry an Origin on the list. A non-nil empty slice is set
// and allows no origin; leave it nil to not configure one.
AllowedOrigins []string

AccessCookieName string
Expand Down
65 changes: 51 additions & 14 deletions origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,35 @@ import (
"strings"
)

// checkOrigin blocks cross-site state changes while cookies are SameSite=None,
// which is when the browser would otherwise attach them to such a request.
// checkOrigin blocks cross-site state changes, whatever the SameSite setting:
// a sibling subdomain is same-site, so even SameSite=Lax cookies ride along on
// its form POST.
//
// Sec-Fetch-Site is the primary signal, since page script cannot forge it:
// cross-site is refused. same-site also covers a sibling subdomain, so with
// cross-site is refused, as is an Origin of null before anything else.
// same-site also covers a sibling subdomain, so with
// AllowedOrigins set its Origin must be on the list too. Without the header,
// Origin is matched against AllowedOrigins when they are set.
// Origin is matched against AllowedOrigins when they are set. A non-nil empty
// AllowedOrigins is set, and allows no origin.
func (a *Adapter) checkOrigin(r *http.Request) (result, bool) {
if a.opts.CookieSameSite != http.SameSiteNoneMode {
return result{}, false
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
if !isUnsafeMethod(r.Method) {
return result{}, false
}

blocked := errorResult(http.StatusForbidden, "cross_site_request_blocked")
origin := r.Header.Get("Origin")
listed := a.opts.AllowedOrigins != nil

// An opaque or sandboxed origin is cross-site, whatever Sec-Fetch-Site says.
if strings.EqualFold(strings.TrimSpace(origin), "null") {
return blocked, true
}

if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
switch {
case strings.EqualFold(site, "cross-site"):
return blocked, true
case strings.EqualFold(site, "same-site") && len(a.opts.AllowedOrigins) > 0:
case strings.EqualFold(site, "same-site") && listed:
return blocked, !a.isAllowedOrigin(origin)
default:
return result{}, false
Expand All @@ -39,15 +44,47 @@ func (a *Adapter) checkOrigin(r *http.Request) (result, bool) {
if origin == "" {
return result{}, false
}
if origin == "null" {
return blocked, true
}
if len(a.opts.AllowedOrigins) == 0 {
if !listed {
return result{}, false
}
return blocked, !a.isAllowedOrigin(origin)
}

// checkContentType refuses an unsafe request that neither declares a JSON body
// nor carries the transport header. Either one makes a cross-origin browser
// request send a CORS preflight, which a form cannot, so this holds even when
// the body is empty and there is nothing for the body check to read.
//
// POST /refresh and DELETE /logout (and /logout/all) are exempt for now: the
// client sends them with no body and neither header, and refusing them would
// sign users out when the adapter upgrades ahead of the client. They keep the
// origin check.
func (a *Adapter) checkContentType(r *http.Request, path string) (result, bool) {
if !isUnsafeMethod(r.Method) || isExplicitJSON(r.Header.Get("Content-Type")) || r.Header.Get(transportHeader) != "" {
return result{}, false
}
if isRefreshRoute(r.Method, path) || isLogoutRoute(r.Method, path) {
return result{}, false
}
return errorResult(http.StatusUnsupportedMediaType, "unsupported_media_type"), true
}

func isRefreshRoute(method, path string) bool {
return method == http.MethodPost && strings.EqualFold(path, "/refresh")
}

func isLogoutRoute(method, path string) bool {
return method == http.MethodDelete && (strings.EqualFold(path, "/logout") || strings.EqualFold(path, "/logout/all"))
}

func isUnsafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
}
return true
}

// isAllowedOrigin matches origin against AllowedOrigins. An origin that does not
// normalize, or is missing, matches nothing, including an allowlist entry that
// does not normalize either.
Expand Down
Loading
Loading