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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,21 @@ 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.
147 changes: 147 additions & 0 deletions adminpolicy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package jawsauth

import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/linkdata/jaws"
)

func TestAdminEmailPolicy(t *testing.T) {
for _, tc := range []struct {
name string
claims map[string]any
listed, verified bool
}{
{"verified", map[string]any{"email": "Admin@Example.COM", "email_verified": true}, true, true},
{"unverified", map[string]any{"email": "admin@example.com", "email_verified": false}, true, false},
{"entra", map[string]any{"email": "admin@example.com"}, true, false},
{"mail", map[string]any{"mail": "admin@example.com", "email_verified": true}, true, false},
{"publicEmail", map[string]any{"email": "invalid", "public_email": "admin@example.com", "email_verified": true}, true, false},
{"unicode", map[string]any{"email": "Kate@example.com", "email_verified": true}, false, true},
{"unicodeListed", map[string]any{"email": "ÅSA@example.com", "email_verified": true}, true, true},
{"unicodeDifferentCase", map[string]any{"email": "åsa@example.com", "email_verified": true}, false, true},
{"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()
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(t.Context(), sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil {
t.Fatal(err)
}
auth := &JawsAuth{server: srv, sess: sess}
if auth.EmailVerified() != tc.verified {
t.Fatal("incorrect verification provenance")
}
for _, strict := range []bool{false, true} {
srv.RequireVerifiedAdminEmail = strict
want := tc.listed && (!strict || tc.verified)
if auth.IsAdmin() != want {
t.Fatalf("strict=%v: template admin=%v", strict, auth.IsAdmin())
}
rec := httptest.NewRecorder()
srv.WrapAdmin(testStatusHandler{http.StatusOK}).ServeHTTP(rec, req)
if (rec.Code == http.StatusOK) != want {
t.Fatalf("strict=%v: status=%d", strict, rec.Code)
}
rec = httptest.NewRecorder()
srv.Wrap(testStatusHandler{http.StatusOK}).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatal("ordinary authentication was restricted")
}
}
srv.SetAdmins(nil)
if !auth.IsAdmin() {
t.Fatal("empty list should allow authenticated users")
}
})
}
}

func TestUserInfoClaimProvenance(t *testing.T) {
for _, tc := range []struct {
name string
id, info map[string]any
email string
verified bool
}{
{"matching", map[string]any{"sub": "one"}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "user@example.com", true},
{"differentSubject", map[string]any{"sub": "one"}, map[string]any{"sub": "two", "email": "user@example.com", "email_verified": true}, "", false},
{"missingSubject", map[string]any{"sub": "one"}, map[string]any{"email": "user@example.com", "email_verified": true}, "", false},
{"missingIDSubject", map[string]any{}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "", false},
{"sameEmail", map[string]any{"sub": "one", "email": "User@Example.com"}, map[string]any{"sub": "one", "email": "User@Example.com", "email_verified": true}, "User@Example.com", true},
{"differentLocalCase", map[string]any{"sub": "one", "email": "User@example.com"}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "User@example.com", false},
{"differentDomainCase", map[string]any{"sub": "one", "email": "user@Example.com"}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "user@Example.com", false},
{"differentFormatting", map[string]any{"sub": "one", "email": "User <user@example.com>"}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "User <user@example.com>", false},
{"differentWhitespace", map[string]any{"sub": "one", "email": " user@example.com"}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, " user@example.com", false},
{"differentEmail", map[string]any{"sub": "one", "email": "id@example.com"}, map[string]any{"sub": "one", "email": "info@example.com", "email_verified": true}, "id@example.com", false},
{"unpairedFlag", map[string]any{"sub": "one", "email_verified": true}, map[string]any{"sub": "one", "email": "info@example.com"}, "info@example.com", false},
{"IDWins", map[string]any{"sub": "one", "email": "id@example.com", "email_verified": true}, map[string]any{"sub": "one", "email": "info@example.com", "email_verified": false}, "id@example.com", true},
{"IDUnverified", map[string]any{"sub": "one", "email": "user@example.com", "email_verified": false}, map[string]any{"sub": "one", "email": "user@example.com", "email_verified": true}, "user@example.com", false},
} {
t.Run(tc.name, func(t *testing.T) {
mergeUserInfoClaims(tc.id, tc.info)
email, _ := tc.id["email"].(string)
if email != tc.email || extractEmailVerified(tc.id) != tc.verified {
t.Fatalf("unexpected merged claims: %v", tc.id)
}
})
}
}

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()
srv, err := New(jw, nil, nil)
if err != nil {
t.Fatal(err)
}
srv.RequireVerifiedAdminEmail = true
srv.SetAdmins([]string{"admin@example.com"})
sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
defer srv.Logout(sess, nil)
auth := &JawsAuth{server: srv, sess: sess}
expiry := time.Now().Add(time.Hour)
start := make(chan struct{})
var workers sync.WaitGroup
workers.Go(func() {
<-start
for range 1000 {
for _, state := range []struct {
email string
verified bool
}{{"user@example.com", true}, {"admin@example.com", false}} {
claims := map[string]any{"sub": "user", "email": state.email, "email_verified": state.verified}
if err := srv.storeSessionAuthClaims(t.Context(), sess, claims, nil, expiry, nil); err != nil {
t.Error(err)
return
}
}
}
})
workers.Go(func() {
<-start
for range 1000 {
if auth.IsAdmin() {
t.Error("admin check combined email and verification from different updates")
return
}
}
})
close(start)
workers.Wait()
}
11 changes: 8 additions & 3 deletions authsession.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,21 +105,24 @@ func (srv *Server) storeSessionAuthClaims(ctx context.Context, sess *jaws.Sessio
err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry}
if !expiry.IsZero() {
if fallback, e := srv.fetchUserInfo(ctx, srv.userinfoUrl, tokenSource); srv.Jaws.Log(e) == nil {
mergeMissingClaims(claims, fallback)
mergeUserInfoClaims(claims, fallback)
}
if entry != nil {
if !srv.sessionAuthTimerCurrent(sess, entry) {
err = errAuthTimerStale
return
}
}
verified := extractEmailVerified(claims)
email, verified := srv.extractEmail(claims)
claims["email_verified"] = verified
// Publish the email and its verification together for admin checks.
srv.mu.Lock()
sess.Set(srv.SessionKey, claims)
sess.Set(srv.SessionTokenKey, tokenSource)
sess.Set(oauth2IDTokenExpiryKey, expiry)
sess.Set(srv.SessionEmailKey, srv.extractEmail(claims))
sess.Set(srv.SessionEmailKey, email)
sess.Set(srv.SessionEmailVerifiedKey, verified)
srv.mu.Unlock()
srv.Jaws.Dirty(sess)
srv.scheduleSessionAuthTimer(sess, expiry)
err = nil
Expand Down Expand Up @@ -360,11 +363,13 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, callLo
if srv != nil && sess != nil {
if srv.stopSessionAuthTimer(sess, entry) {
clearSessionOAuthFlow(sess)
srv.mu.Lock()
sess.Set(srv.SessionKey, nil)
sess.Set(srv.SessionTokenKey, nil)
sess.Set(oauth2IDTokenExpiryKey, nil)
sess.Set(srv.SessionEmailKey, nil)
sess.Set(srv.SessionEmailVerifiedKey, nil)
srv.mu.Unlock()
if callLogout && srv.LogoutEvent != nil {
srv.LogoutEvent(sess, hr)
}
Expand Down
2 changes: 1 addition & 1 deletion authsession_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) {
userinfoAuth = hr.Header.Get("Authorization")
mu.Unlock()
hw.Header().Set("Content-Type", "application/json")
_, _ = hw.Write([]byte(`{"name":"Profile Name"}`))
_, _ = hw.Write([]byte(`{"sub":"sub-123","name":"Profile Name"}`))
default:
hw.WriteHeader(http.StatusNotFound)
}
Expand Down
13 changes: 10 additions & 3 deletions jawsauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ func (a *JawsAuth) Data() (x map[string]any) {
}

// Email returns the authenticated email stored in the session, or an empty string.
//
// The value is a parsed address with ASCII letters lowercased.
// Use [JawsAuth.IsAdmin] to check administrator status.
// It is safe to call on a nil or zero-value JawsAuth.
func (a *JawsAuth) Email() (s string) {
if a != nil && a.server != nil && a.sess != nil {
Expand All @@ -27,7 +30,9 @@ func (a *JawsAuth) Email() (s string) {
return
}

// EmailVerified returns whether the authenticated email was marked verified.
// EmailVerified reports whether the session address came from a verified email claim.
//
// Addresses taken from mail or public_email claims are unverified.
// It is safe to call on a nil or zero-value JawsAuth.
func (a *JawsAuth) EmailVerified() (yes bool) {
if a != nil && a.server != nil && a.sess != nil {
Expand All @@ -37,12 +42,14 @@ func (a *JawsAuth) EmailVerified() (yes bool) {
}

// IsAdmin reports whether the authenticated email is an administrator.
// A nil or zero-value JawsAuth follows Server.IsAdmin's nil-server behavior and returns true.
//
// It applies [Server.RequireVerifiedAdminEmail] to non-empty admin lists.
// A nil or zero-value JawsAuth follows [Server.IsAdmin]'s nil-server behavior and returns true.
func (a *JawsAuth) IsAdmin() (yes bool) {
if a == nil || a.server == nil {
yes = true
} else {
yes = a.server.IsAdmin(a.Email())
yes = a.server.sessionIsAdmin(a.sess)
}
return
}
29 changes: 24 additions & 5 deletions oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,22 +187,41 @@ func randomHexString() string {
return hex.EncodeToString(b[:])
}

func mergeMissingClaims(dst, src map[string]any) {
if dst != nil {
for k, v := range src {
// mergeUserInfoClaims accepts fallback claims only for the verified subject.
// Email verification stays paired with the email from the same response.
func mergeUserInfoClaims(dst, src map[string]any) {
subject, _ := dst["sub"].(string)
if other, _ := src["sub"].(string); subject == "" || other != subject {
return
}
_, hasEmail := dst["email"]
if !hasEmail {
if email, ok := src["email"]; ok {
dst["email"] = email
dst["email_verified"] = src["email_verified"]
}
} else if _, hasVerified := dst["email_verified"]; !hasVerified {
email, _ := dst["email"].(string)
other, _ := src["email"].(string)
if email != "" && email == other {
dst["email_verified"] = src["email_verified"]
}
}
for k, v := range src {
if k != "email_verified" {
if _, ok := dst[k]; !ok {
dst[k] = v
}
}
}
}

func (srv *Server) extractEmail(claims map[string]any) (sessEmailValue any) {
func (srv *Server) extractEmail(claims map[string]any) (sessEmailValue any, verified bool) {
for _, k := range []string{"email", "mail", "public_email"} {
if s, ok := claims[k].(string); ok {
if s = strings.TrimSpace(s); s != "" {
if m, err := mail.ParseAddress(s); err == nil {
return strings.ToLower(strings.TrimSpace(m.Address))
return foldEmailCase(m.Address), k == "email" && extractEmailVerified(claims)
}
}
}
Expand Down
19 changes: 17 additions & 2 deletions oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) {
gotAuth = hr.Header.Get("Authorization")
mu.Unlock()
hw.Header().Set("Content-Type", "application/json")
_, _ = hw.Write([]byte(`{"email":"userinfo@example.com","name":"Profile Name"}`))
_, _ = hw.Write([]byte(`{"sub":"sub-123","email":"userinfo@example.com","name":"Profile Name"}`))
default:
setProviderErr(fmt.Errorf("unexpected provider path %s", hr.URL.Path))
hw.WriteHeader(http.StatusNotFound)
Expand Down Expand Up @@ -1182,6 +1182,21 @@ func TestServerExtractEmail(t *testing.T) {
userinfo: map[string]any{"email": `"Test User" <TestUser@Example.com>`},
want: "testuser@example.com",
},
{
name: "literalQuotesInLocalPart",
userinfo: map[string]any{"email": `"\"Quoted\""@Example.com`},
want: `"quoted"@example.com`,
},
{
name: "literalSpacesInLocalPart",
userinfo: map[string]any{"email": `" User "@Example.com`},
want: " user @example.com",
},
{
name: "nonASCIICasePreserved",
userinfo: map[string]any{"email": "ÅSA@Example.com"},
want: "Åsa@example.com",
},
{
name: "mailFieldFallback",
userinfo: map[string]any{"mail": "Secondary@Example.com "},
Expand Down Expand Up @@ -1210,7 +1225,7 @@ func TestServerExtractEmail(t *testing.T) {
handler := &recordingHandler{}
jw.Logger = slog.New(handler)
srv := &Server{Jaws: jw}
got := srv.extractEmail(tc.userinfo)
got, _ := srv.extractEmail(tc.userinfo)
if tc.expectNil {
if got != nil {
t.Fatalf("expected nil email value, got %v", got)
Expand Down
Loading
Loading