Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c39e4c3
feat(auth): isolate provider session keys
linkdata Nov 4, 2026
db7080c
feat(auth): support OAuth profile linking and bounded flows
linkdata Nov 4, 2026
1f26ab6
fix(auth): bound provider callbacks and session retention
linkdata Nov 4, 2026
ea903d1
test(auth): isolate overlapping provider flows
linkdata Nov 4, 2026
4726e13
fix: cancel stale OAuth callbacks on logout
linkdata Nov 4, 2026
ee25930
fix: reject invalid OAuth identities at callback completion
linkdata Nov 4, 2026
c3ee58e
fix: handle short profile tokens and offline consent
linkdata Nov 4, 2026
17cad04
fix(oauth): honor UserInfo deadlines and token expiry
linkdata Nov 4, 2026
2db75e6
fix: preserve OAuth flows across auth expiry
linkdata Nov 4, 2026
1dfddcf
fix: reject OAuth token endpoint redirects
linkdata Nov 4, 2026
51fc0b4
ci: update Staticcheck for Go 1.27
linkdata Nov 4, 2026
dee95e7
ci: update golangci-lint for Go 1.27
linkdata Nov 4, 2026
da37551
ci: update gosec for Go 1.27
linkdata Nov 4, 2026
6d11b14
feat: serialize OAuth session rotation with link writes
linkdata Nov 4, 2026
dd15208
fix: preserve OAuth flows across concurrent auth changes
linkdata Nov 4, 2026
a105ae7
fix: bound pending login grace and stale auth timers
linkdata Nov 4, 2026
cc839ed
docs: describe bounded auth timer cleanup
linkdata Nov 4, 2026
784685d
fix: bound OAuth refresh and login redirects
linkdata Nov 4, 2026
5e48aea
test: bound OAuth callback synchronization waits
linkdata Nov 4, 2026
be6d723
fix: enforce HTTPS for discovered OIDC endpoints
linkdata Nov 4, 2026
86fc195
fix: serialize auth revocation and bound OIDC verification
linkdata Nov 4, 2026
9111a3e
test: assert locked revocation callback
linkdata Nov 4, 2026
aaa6714
fix: serialize built-in logout with account links
linkdata Nov 4, 2026
d8024f6
fix(auth): preserve pending login and simplify provider flows
linkdata Nov 4, 2026
7e1831b
fix: serialize link callbacks with auth revocation
linkdata Nov 4, 2026
679a075
fix: simplify OAuth flows and guard refresh identity
linkdata Nov 4, 2026
7885841
fix: simplify OAuth callbacks and clarify provider docs
linkdata Nov 4, 2026
6eb62b4
fix: guard linked identity expiry and simplify OAuth callbacks
linkdata Nov 4, 2026
f677332
fix: reuse pending OAuth login and clarify auth contracts
linkdata Nov 5, 2026
46dda49
fix: serialize OAuth starts and require verified profile admins
linkdata Nov 5, 2026
914c3af
fix: order auth events and simplify OAuth flow
linkdata Nov 5, 2026
351cefd
fix: preserve callback paths and defer logout safely
linkdata Nov 5, 2026
254569a
fix(auth): tighten return targets and provider timeouts
linkdata Nov 5, 2026
36b1a65
fix(auth): renew nearly expired login flows
linkdata Nov 5, 2026
e5256aa
fix(auth): share default rotation lock across providers
linkdata Nov 5, 2026
c763eb4
fix(auth): bind session claims to provider issuer
linkdata Nov 5, 2026
6e5c988
fix(auth): refresh early and simplify callback handling
linkdata Nov 5, 2026
476fc8d
fix(auth): stop old timer during login rotation
linkdata Nov 5, 2026
df67b3d
refactor: simplify auth timer and clarify provider guidance
linkdata Nov 5, 2026
05fb2cc
test: cover stale auth timer and clarify session guidance
linkdata Nov 5, 2026
e542213
test: cover linking revocation and callback limits
linkdata Nov 5, 2026
fc41b68
fix: bind OAuth flows to originating server
linkdata Nov 5, 2026
b65e0ba
fix: prevent cross-provider OAuth redirect reuse
linkdata Nov 5, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:

- name: Staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@1285a6a5ec1e0ebb658f49e82b6c566a878cc3cb # v0.8.1
go install honnef.co/go/tools/cmd/staticcheck@54455593555daf6bfcf3c480faaada7de3145357 # v0.8.2
staticcheck ./...

- name: Staticcheck (cmd)
Expand All @@ -66,16 +66,16 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.2
version: v2.15.0

- name: golangci-lint (cmd)
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.2
version: v2.15.0
working-directory: cmd

- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0
run: go install github.com/securego/gosec/v2/cmd/gosec@5eb1d104ef85ff2c905c877eb600e8c1dee33a22 # v2.30.0

- name: Run Gosec Security Scanner
run: gosec ./...
Expand Down
158 changes: 104 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,107 @@

# jawsauth

OIDC-verified authentication for [JaWS](https://github.com/linkdata/jaws) sessions.

- Requires an OIDC-compliant provider.
- Uses OIDC discovery from the configured issuer.
- Verifies `id_token` and stores identity claims in session data.
- Adds PKCE (S256) and OIDC nonce verification to the authorization-code flow.
- Automatically refreshes the `id_token` in the background before it expires.
- Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`.

Protected routes create JaWS sessions for unauthenticated visitors. Set
`Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound session counts. When a
session cannot be created, login returns 503 with `Retry-After: 60`.
Post-login return targets longer than 8192 bytes fall back to `/`.

Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII
characters are matched exactly. Session email values use the same case rules.
Email verification is not required by default. This supports providers such as
Microsoft Entra ID that omit `email_verified`, but relies on the provider's policy
for who may claim an address. Microsoft [advises against using its email claim for
authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference).
To require verified email for a non-empty admin allowlist, set
`Server.RequireVerifiedAdminEmail = true` before serving requests. This affects
`WrapAdmin`, `HandlerAdmin`, and `JawsAuth.IsAdmin`; ordinary login stays available.
Providers that omit the verification claim cannot grant admin access in this mode.
`Server.IsAdmin(email)` only checks address membership. `mail` and `public_email`
are always considered unverified; they can match the allowlist only when strict
mode is disabled.
An empty admin list allows every authenticated user through the HTTP admin gates.
UserInfo fallback requires a matching non-empty `sub`; its verification flag is
used only when that response supplies the email or confirms the exact same email
claim, including case and whitespace.

Successful login rotates the JaWS session and discards pre-login application data.
UserInfo is fetched before rotation so its latency does not consume the new
session's idle lifetime.
Rotation requires a free slot under both session limits until the new cookie is
published. In particular, `MaxSessionsPerIP = 1` prevents login; allow room for
rotation when setting limits. If no slot is available, the default response is 503
and the existing session's authentication and refresh timer are preserved.
Restart login after capacity becomes available.

Logout and auth expiry clear authentication and reload the session's JaWS pages.
Connected pages receive the reload command before their sockets close; pending
pages reload when they connect. Protected renders also recheck authorization when
the HTTP handler returns, retiring requests attached during a concurrent revocation.
Events and callbacks run normally until disconnection. Protected operations must
check current claims and expiry, plus `JawsAuth.IsAdmin` for administrator actions.
Changing `SetAdmins` also reloads every page of sessions that lose admin access;
their ordinary login remains valid.
Use an HTTP endpoint for logout redirects: calling `Request.Redirect` after
`Server.Logout` in a JaWS event cannot send the redirect because Reload closes the
connection and discards later messages. Closed or expired JaWS sessions have their
tokens and timers discarded at the next scheduled refresh, which calls
`LogoutEvent` with a nil HTTP request.
OIDC and OAuth2 profile authentication for [JaWS](https://github.com/linkdata/jaws)
sessions. Both modes use authorization-code state and PKCE (S256). `Wrap` and
`Handler` require a login; `WrapAdmin` and `HandlerAdmin` also apply the
`SetAdmins` allowlist.

## Setup

`Config` requires `RedirectURL`, `Issuer` and `ClientID`. The issuer, effective
provider endpoints and JWKS URI must use HTTPS;
`AllowInsecureIssuer` permits HTTP for tests and development. OIDC mode
discovers provider endpoints, verifies the ID token and nonce, and attempts
refresh before expiry when the provider issues a refresh token. Request offline
access with the `offline_access` scope for OIDC providers that support it, or
`oauth2.AccessTypeOffline` in `Server.Options` for Google. Check both the error
from `New` and `Server.Valid()` before serving protected routes; an invalid server
can return handlers without authentication.

Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a
`ProfileSubject` function that extracts a stable subject from the authenticated
profile. Profile mode sets the `iss` claim to `Issuer` and `sub` to the
`ProfileSubject` result. The `iss`+`sub` pair identifies an account, so use a
distinct `Issuer` for each provider. Email and names are not identity proof.
Profile JSON numbers arrive as `json.Number` to preserve large IDs. Profile
authentication expires after 24 hours or the access token's earlier expiry.
An explicit `expires_in: 0` in the token response is rejected.

Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by
unauthenticated requests; both limits are unlimited by default. A login start,
including an unauthenticated protected GET, can retain its session for about ten
minutes even if the client never reaches the provider. Login rotates the session
and discards its application data; rotation needs a free slot under both limits.
`MaxSessionsPerIP = 1` prevents login. If a slot is unavailable,
login returns 503 with `Retry-After: 60` and preserves the existing session.

## Multiple providers

- Providers sharing JaWS must have distinct `Namespace` values and callback
directories; a shared namespace can clear another provider's login. Add each
provider's registered paths to the other servers' `HandledPaths` so return
targets cannot enter another provider's auth endpoints.
- `New` replaces `Jaws.MakeAuth` when it configures authentication. When servers
share JaWS, capture `jw.MakeAuth` immediately after each configured `New`, then
install a composite function so templates use the intended provider's
authentication and admin policy. Each
server's `Wrap`, `Handler` and `CurrentClaims` check that provider's
authentication.
- Register `HandleLink` as a POST handler to enable linking; `New` does not
register it. Authorize the session before calling it. In `LinkEvent`, recheck
the original provider's `CurrentClaims` before writing the link. Linking keeps
the session; an ordinary login rotates it and signs out other providers.
A re-login replacing existing auth calls its `LogoutEvent` before storing
the new sign-in. Calling `Logout` on that event session cancels it. Other
providers' `LogoutEvent` may run later with a nil request. Before using one to
log out another provider, check its `CurrentClaims`; the session may have rotated.
Starting a link replaces this server's pending flow, including a running
login callback.
- `New` shares one process-wide `SessionRotationLock` by default. To coordinate
with other authentication methods or isolate independent JaWS instances, set
a custom lock before serving; servers sharing a JaWS must use the same lock.
jawsauth holds it during flow start, rotation, authentication clearing and
`LinkEvent`.
Do not take the lock around jawsauth handlers or `Server.Logout`, or within
`LinkEvent`; do not call `Server.Logout` from `LinkEvent`. Bound `LinkEvent` I/O
with a timeout; other sessions sharing the lock wait. One provider's logout
does not cancel another provider's pending link.

## Admin access

Profile mode requires a verified email for a non-empty admin list by default;
OIDC mode does not. Set `RequireVerifiedAdminEmail = true` in OIDC mode unless
the provider guarantees verified addresses. In profile mode, `ProfileSubject`
may map a provider verification signal to `email_verified`; disable the check
only with the same guarantee. Providers without a verification signal cannot
grant admin access while the check is enabled.
[Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference);
use application policy based on a stable identifier for Entra ID.
OIDC UserInfo fallback requires its `sub` to match the ID token's nonempty `sub`. Its
`email_verified` applies only to an email it supplies or one matching the ID
token email exactly. `mail` and `public_email` never count as verified.

`SetAdmins` parses valid addresses and retains other nonempty entries. It trims
whitespace and ignores ASCII case. An empty list allows every authenticated user
through the admin handlers.
`Server.IsAdmin(email)` checks only list membership; `JawsAuth.IsAdmin` and the
admin handlers enforce email verification when required. Neither `IsAdmin`
method proves a live login; check `CurrentClaims` before protected actions.

## Session lifetime

Pending OAuth flows expire after ten minutes. jawsauth extends the original
JaWS session's grace for the pending flow. Unauthenticated
protected GETs reuse a pending login's state, return target and grace deadline
while more than two minutes remain; near expiry, they start a new flow and
extend grace. While a login callback runs before session rotation, protected GETs
with that session cookie return 409 unless `LoginFailed` handles them. After
rotation, the old cookie starts a new flow and cannot cancel that sign-in. Logout
and auth expiry clear this server's credentials and reload every JaWS page in the
session. Auth expiry preserves a pending login but cancels a pending link. Explicit
logout on the live session cancels either flow. A new login on the same server,
including one started by `Wrap` or `Handler`, cancels a pending link.

Use an HTTP endpoint for a post-logout redirect. When `Server.Logout` clears
authentication, it reloads pages and closes their sockets, so a later JaWS
`Request.Redirect` cannot deliver the redirect.
62 changes: 49 additions & 13 deletions adminpolicy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"sync"
"testing"
"time"

"github.com/linkdata/jaws"
)

func TestAdminEmailPolicy(t *testing.T) {
Expand All @@ -27,16 +25,12 @@ func TestAdminEmailPolicy(t *testing.T) {
{"quotedLocalPart", map[string]any{"email": `"\"quoted\""@example.com`, "email_verified": true}, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
jw, err := jaws.New()
if err != nil {
t.Fatal(err)
}
defer jw.Close()
jw := newTestJaws(t)
srv := newTimerTestServer(t, jw, "https://issuer.example", &testAuthTimerFactory{})
srv.SetAdmins([]string{"admin@example.com", "kate@example.com", "Åsa@example.com", `"\"quoted\""@example.com`})
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess := jw.NewSession(httptest.NewRecorder(), req)
if err = srv.storeSessionAuthClaims(sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil {
if err := srv.storeSessionAuthClaims(sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil {
t.Fatal(err)
}
auth := &JawsAuth{server: srv, sess: sess}
Expand Down Expand Up @@ -68,6 +62,52 @@ func TestAdminEmailPolicy(t *testing.T) {
}
}

func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) {
for _, tc := range []struct {
name string
verified, optOut bool
want int
}{
{"default", false, false, http.StatusForbidden},
{"verified by profile", true, false, http.StatusOK},
{"explicit opt-out", false, true, http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
jw := newTestJaws(t)
srv := newProfileTestServer(t, jw, nil)
originalSubject := srv.profileSubject
srv.profileSubject = func(claims map[string]any) (string, error) {
if tc.verified {
claims["email_verified"] = true
}
return originalSubject(claims)
}
if !srv.RequireVerifiedAdminEmail {
t.Fatal("profile mode did not require verified admin email by default")
}
if tc.optOut {
srv.RequireVerifiedAdminEmail = false
}
srv.SetAdmins([]string{"first@example.com"})
cookie := profileLogin(t, srv)
r := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil)
r.AddCookie(cookie)
sess := jw.GetSession(r)
if sess == nil {
t.Fatal("missing profile session")
}
if sess.Get(srv.SessionEmailVerifiedKey) != tc.verified {
t.Fatalf("profile verification = %v, want %t", sess.Get(srv.SessionEmailVerifiedKey), tc.verified)
}
w := httptest.NewRecorder()
srv.WrapAdmin(testStatusHandler{http.StatusOK}).ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("profile admin email: status=%d, want %d", w.Code, tc.want)
}
})
}
}

func TestUserInfoClaimProvenance(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down Expand Up @@ -101,11 +141,7 @@ func TestUserInfoClaimProvenance(t *testing.T) {

func TestAdminEmailPolicyConcurrentUpdates(t *testing.T) {
// Exercise real concurrent updates and reads without forcing an interleaving.
jw, err := jaws.New()
if err != nil {
t.Fatal(err)
}
defer jw.Close()
jw := newTestJaws(t)
srv, err := New(jw, nil, nil)
if err != nil {
t.Fatal(err)
Expand Down
Loading
Loading