Skip to content

feat(oauth): implement oidc contract with a generic provider and preset based on google and stackit - #36

Merged
Saxy merged 7 commits into
mainfrom
feat/oauth
Aug 9, 2026
Merged

feat(oauth): implement oidc contract with a generic provider and preset based on google and stackit#36
Saxy merged 7 commits into
mainfrom
feat/oauth

Conversation

@Saxy

@Saxy Saxy commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Description

Component:
Authentication

Type of Change:

  • New feature (non-breaking change which adds functionality)

Related Issue

Closes #10
Closes #34


Technical Deep Dive & Context

Connection-time OIDC bearer-token authentication: a client presents a Google/STACKIT/any-issuer id_token (signed JWT) as its AUTH credential; its claims are mapped to an RBAC role via the policy's oauth.rules and pinned to the connection exactly like a password session.

Provider contract (internal/oauth/oauth.go): Provider.Verify(ctx, token) (Claims, error) with Claims = map[string][]string (JWT claims may repeat or be lists; never flattened). Two error classes — ErrInvalidToken for bad credentials (callers find it via errors.Is), unwrapped transient errors so "IdP down" is distinguishable from "bad credential". No secrets in Config: verification is signature + issuer + audience against the public JWKS, so no client secret is needed server-side.

Generic OIDC provider (internal/oauth/generic, stdlib-only, fail-fast): synchronous discovery + JWKS fetch in New (wrong issuer/IdP fails at startup, not first AUTH); algorithm allowlist RS256/ES256 only (HS* rejected to prevent algorithm-confusion); kid required; exactly one JWKS refresh on unknown kid (rotation without restarts); exp/nbf/iss/aud enforced; JWKS cache behind an RWMutex, whole-set replacement on refresh. Presets google/stackit are thin wrappers — Google maps the hd claim into groups so claim: groups, match: "*@example.com" works one-line. Registry (internal/oauth/registry.go) is first-wins, read-only after startup, lock-free reads.

RBAC integration (internal/rbac/policy.go): oauth.rules compiled at policy load (unknown target role fails the load; a middle * wildcard is rejected). Store.ResolveOAuthToken is a closure so rbac stays independent of the oauth package. First-match-wins; no match or verify failure = no role = deny-all (fail-closed). Session subject = token sub (falls back to "default"); audit events and ACL LOG account failures identically to the password path. SIGHUP hot-reload never re-evaluates pinned sessions.

Hot-path allocation (binary client, client.go / client_role.go): real id_tokens routinely exceed the 512-byte stack buffer used by Auth/AuthUser. The stack path stays allocation-free; payloads that overflow fall back to a one-time heap buffer. Before this, >512-byte credentials were rejected with ErrRequestTooLarge — the bundled client could not authenticate over the binary protocol at all.

Startup config (config/config.go, server/server.go): --oauth-provider (google|stackit, empty → generic), --oauth-issuer, --oauth-client-id, each with a TSD_* env fallback. No flags → provider nil → zero behavior change and zero overhead. Token auth without --rbac-config is a startup error (a token can only reach a role through oauth.rules).

Example (cmd/example/oauth): a client that presents an id_token (from -token/-token-file/TSD_ID_TOKEN/stdin), AUTHs, issues SET/GET, and proves a forged token is denied.


Performance & Benchmarks

Workload: N/A — no measurable change to the data path.


How Has This Been Tested?

  • go test ./... — all packages pass (internal/oauth, internal/oauth/generic, internal/oauth/presets, internal/rbac, internal/resp oauth suites included).
  • go vet ./... — clean.
  • Live end-to-end against the real Google issuer:
    • RESP: AUTH <google id_token>+OK; SET/GET succeed; forged token → -ERR invalid password; session reset to -NOAUTH after failed AUTH.
    • Binary protocol (raw frame + packaged client): pre-auth GET → INVALID_AUTH; AUTH → MsgAuthOk; SET/GET OK; forged token denied; large-token path exercised by TestClientOAuthLargeToken (700-byte JWT-shaped credential through client.Auth).
  • Edge cases covered by unit tests: unknown/absent kid → JWKS refresh once then reject; missing kid rejected; HS256 token rejected; expired/not-yet-valid tokens rejected; multi-value aud accepted; exp absent allowed; policy with *@domain prefix-glob, *@domain suffix-glob, exact, and bare * matches; first-match-wins ordering; fail-closed when no rule matches; middle-* pattern rejected at load; unknown target role fails load; --oauth-provider unknown name → startup error; token auth without --rbac-config → startup error.
  • Manual: cmd/example/oauth against a live server with real Google token (AUTH OK, SET OK, GET value, forged denied INVALID_AUTH).

Checklist

  • My code follows the existing code style of this project
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally (go test ./... and go test -race ./...)
  • I have updated the documentation (README, comments, or any relevant docs)
  • My changes generate no new go vet warnings
  • Any breaking changes are documented and communicated

Summary by CodeRabbit

New Features

  • Added optional OAuth/OIDC bearer-token authentication for binary and RESP connections.
  • Added Google, STACKIT, and generic OIDC provider support with secure token verification.
  • Added configurable claim-to-role mapping with fail-closed authorization.
  • Added OAuth configuration options and an end-to-end authentication example.

Bug Fixes

  • Improved handling of large authentication credentials, safely rejecting values beyond the protocol limit.

Documentation

  • Added OAuth/OIDC setup, provider, policy, and usage documentation.
  • Marked audit logging as completed in the roadmap.

Saxy added 4 commits August 7, 2026 09:05
created oauth provider interfaces and registry
created preset for google and stackit
use generic approach for all other until implemented
implement oauth claims into a map for rbac

Signed-off-by: Maximilian Hagen <git@saxy.dev>
that example shows how to connect with google oidc

Signed-off-by: Maximilian Hagen <git@saxy.dev>
Signed-off-by: Maximilian Hagen <git@saxy.dev>
Signed-off-by: Maximilian Hagen <git@saxy.dev>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Saxy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4cde3e03-5615-4a2b-9e06-01319fb84b0c

📥 Commits

Reviewing files that changed from the base of the PR and between 7f891cd and cd05fbb.

📒 Files selected for processing (5)
  • internal/network/server.go
  • internal/oauth/generic/generic.go
  • internal/oauth/generic/generic_test.go
  • internal/oauth/oauth.go
  • internal/resp/server.go
📝 Walkthrough

Walkthrough

Adds configurable OAuth/OIDC authentication with generic and preset providers, cached JWKS verification, claim-to-role RBAC mapping, bearer-token support in binary and RESP protocols, configuration flags, tests, documentation, and an OAuth example.

Changes

OAuth/OIDC authentication

Layer / File(s) Summary
Provider contracts and OIDC verification
internal/oauth/..., internal/oauth/README.md
Defines OAuth contracts and implements OIDC discovery, JWKS caching, RS256/ES256 verification, claim validation, and normalized claims.
Provider presets and registry
internal/oauth/presets/..., internal/oauth/registry.go
Adds Google and STACKIT presets and provider registration, lookup, validation, and listing.
OAuth claim-to-role resolution
internal/rbac/..., internal/oauth/README.md
Adds ordered claim rules, wildcard matching, policy validation, role resolution, subject-based sessions, and fail-closed handling.
Configuration and server authentication
config/config.go, server/server.go, internal/network/..., internal/resp/...
Adds OAuth configuration and provider startup selection. Binary and RESP AUTH flows detect JWT-shaped credentials, verify them asynchronously, and create RBAC sessions. Client authentication validates protocol credential lengths.
Examples, documentation, and compatibility updates
README.md, ARCHITECTURE.md, ROADMAP.md, cmd/example/..., cmd/tellstone/main.go, go.mod, internal/*/*_test.go
Adds OAuth documentation and an OAuth client example. Updates constructor call sites, logger naming, roadmap status, dependencies, and integration tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Tellstone
  participant OIDCProvider
  participant RBAC
  Client->>Tellstone: AUTH bearer token
  Tellstone->>OIDCProvider: Verify JWT
  OIDCProvider-->>Tellstone: Claims
  Tellstone->>RBAC: Resolve claims to role and subject
  RBAC-->>Tellstone: Session context
  Tellstone-->>Client: Authentication result
Loading

Possibly related PRs

  • Saxy/Tellstone#22 — Extends the RBAC policy with OAuth claim-to-role resolution.
  • Saxy/Tellstone#23 — Relates to the RESP and binary authentication paths extended with OAuth token support.
  • Saxy/Tellstone#29 — Relates to the NewServer constructors and initialization paths updated for OAuth alongside audit support.

Suggested labels: enhancement

Suggested reviewers: 404khai

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes unrelated OpenTelemetry dependency updates, roadmap changes, comment formatting, and logging renames outside the OAuth objectives. Remove unrelated dependency, roadmap, formatting, and logging changes, or move them into separate pull requests.
Docstring Coverage ⚠️ Warning Docstring coverage is 34.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the OAuth/OIDC contract, generic provider, and Google and STACKIT presets.
Description check ✅ Passed The description includes all template sections, implementation details, testing evidence, documentation updates, and completed checklist items.
Linked Issues check ✅ Passed The PR addresses pluggable OIDC for both protocols, provider validation, zero disabled overhead, RBAC claim mapping, fail-closed access, and policy reload support.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/oauth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (10)
internal/oauth/registry.go (1)

79-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix the doc comment verb and consider slices.Sort.

Line 79 reads "Providers return the sorted names". The Go convention for a single function is "Providers returns". slices.Sort is also the current form for a string slice.

♻️ Proposed change
-// Providers return the sorted names of all registered providers. The sort
+// Providers returns the sorted names of all registered providers. The sort
 // keeps help output and error messages deterministic.
 func Providers() []string {
 	names := make([]string, 0, len(registry))
 	for name := range registry {
 		names = append(names, name)
 	}
-	sort.Strings(names)
+	slices.Sort(names)
 	return names
 }

Replace "sort" with "slices" in the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/registry.go` around lines 79 - 88, Update the Providers doc
comment to say “Providers returns” and replace sort.Strings with slices.Sort,
updating the import from sort to slices accordingly.
internal/oauth/generic/generic.go (4)

187-201: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the discovery and JWKS response bodies.

getJSON decodes whatever the endpoint returns. p.client.Timeout bounds the total call duration, but a peer that streams JSON steadily stays inside the timeout while json.Decoder allocates without limit. For the JWKS path this is reachable after startup, because refreshJWKS runs during Verify.

Wrap the body in an io.LimitReader. A real discovery document and JWKS are a few kilobytes.

🛡️ Proposed fix
+// maxDocSize bounds discovery and JWKS responses. Real documents are a few
+// kilobytes, so this only stops a peer that streams JSON inside the timeout.
+const maxDocSize = 1 << 20
+
 func (p *Provider) getJSON(ctx context.Context, url string, out any) error {
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
 	if err != nil {
 		return err
 	}
 	resp, err := p.client.Do(req)
 	if err != nil {
 		return err
 	}
 	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
 		return fmt.Errorf("generic: GET %s returned %s", url, resp.Status)
 	}
-	return json.NewDecoder(resp.Body).Decode(out)
+	return json.NewDecoder(io.LimitReader(resp.Body, maxDocSize)).Decode(out)
 }

This needs "io" in the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/generic/generic.go` around lines 187 - 201, Update
Provider.getJSON to wrap resp.Body with io.LimitReader before passing it to
json.Decoder, using a sufficiently bounded limit for discovery and JWKS
responses. Add the required io import and preserve the existing request, status
validation, and decoding behavior.

142-156: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Validate the discovery document's issuer against the configured issuer.

doc.Issuer is decoded at Line 145 and then discarded. OpenID Connect Discovery requires the issuer value in the document to match the issuer the client used to build the request URL. Without the check, a redirect or a misconfigured endpoint can hand back a document that points jwks_uri at an unrelated key set.

Claim validation at Line 325 still compares the token's iss against cfg.Issuer, so this is defense in depth rather than an open bypass. It is one comparison at startup.

🔒️ Proposed fix
 	if err := p.getJSON(ctx, url, &doc); err != nil {
 		return err
 	}
+	// OIDC Discovery requires the document issuer to equal the issuer used to
+	// build the request, so a redirected or wrong endpoint cannot supply keys.
+	if doc.Issuer != strings.TrimRight(p.cfg.Issuer, "/") && doc.Issuer != p.cfg.Issuer {
+		return fmt.Errorf("generic: discovery issuer %q does not match configured issuer %q", doc.Issuer, p.cfg.Issuer)
+	}
 	if doc.JWKSURI == "" {
 		return errors.New("oauth: discovery document has no jwks_uri")
 	}

The in-process identity providers in internal/oauth/generic/generic_test.go and internal/oauth/presets/google_test.go already return srv.URL as issuer, so the existing tests keep passing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/generic/generic.go` around lines 142 - 156, In
Provider.refreshDiscovery, validate doc.Issuer against the configured issuer
after decoding the discovery document and before accepting doc.JWKSURI;
normalize trailing slashes consistently with the request URL and return an error
on mismatch. Preserve the existing missing-jwks_uri check and refreshJWKS flow
for a matching issuer.

244-256: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deprecated ecdsa.PublicKey coordinate fields in internal/oauth/generic/generic.go and internal/oauth/generic/generic_test.go. Both sites move EC public keys through the raw X and Y big.Int fields, which Go deprecated. The Go 1.26 API audit lists PublicKey.X and PublicKey.Y as deprecated. The documentation directs callers to PublicKey.Bytes and ParseUncompressedPublicKey. golangci-lint already fails on the test site with SA1019, so task check does not pass.

  • internal/oauth/generic/generic.go#L244-L256: in parseJWK, build the P-256 key from the uncompressed point with ecdsa.ParseUncompressedPublicKey(elliptic.P256(), append([]byte{4}, xPadded..., yPadded...)) instead of assigning X and Y. Left-pad each coordinate to 32 bytes, because a JWK omits leading zero bytes. This also validates that the point lies on the curve, which the current code does not do.
  • internal/oauth/generic/generic_test.go#L98-L110: in jwksFromKeys, derive the JWK coordinates from pk.Bytes() and slice the uncompressed point into X and Y rather than reading pk.X and pk.Y.

Confirm the repository's declared Go toolchain first, because ParseUncompressedPublicKey and PublicKey.Bytes need Go 1.25 or later. The verification script on internal/oauth/generic/generic.go Line 244-256 reports the declared version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/generic/generic.go` around lines 244 - 256, Update
internal/oauth/generic/generic.go:244-256 in parseJWK to confirm the declared Go
toolchain supports the newer APIs, then left-pad decoded EC coordinates to 32
bytes and construct the P-256 key with ecdsa.ParseUncompressedPublicKey using
the uncompressed point format, preserving decode errors and validating curve
membership. Update internal/oauth/generic/generic_test.go:98-110 in jwksFromKeys
to derive coordinates from pk.Bytes() and slice the uncompressed point instead
of accessing deprecated pk.X and pk.Y.

Source: Linters/SAST tools


126-129: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse the split from splitToken instead of splitting the token again.

splitToken at Line 98 already split the token. Line 126 re-runs strings.Split(string(token), "."), which allocates a second string copy and a second slice, then Line 127 concatenates the first two segments into a third allocation. Verify runs once per AUTH on the auth-worker pool, so this is not a correctness problem, but the file states that Verify "stays local and allocation-light".

Return the signing input from splitToken and drop the second split.

♻️ Proposed refactor
-	segments := strings.Split(string(token), ".")
-	if err = verifySignature(hdr.Alg, key, []byte(segments[0]+"."+segments[1]), sig); err != nil {
+	if err = verifySignature(hdr.Alg, key, signing, sig); err != nil {
 		return nil, oauth.ErrInvalidToken
 	}

Change the helper to hand back the signed bytes, which are a subslice of the input and cost no allocation:

// splitToken decodes the three JWT segments and returns the signing input
// (header.payload) as a subslice of token, so the caller never re-splits.
func splitToken(token []byte) (header, payload, sig, signing []byte, err error) {
	first := bytes.IndexByte(token, '.')
	if first < 0 {
		return nil, nil, nil, nil, oauth.ErrInvalidToken
	}
	second := bytes.IndexByte(token[first+1:], '.')
	if second < 0 {
		return nil, nil, nil, nil, oauth.ErrInvalidToken
	}
	second += first + 1
	sigPart := token[second+1:]
	if bytes.IndexByte(sigPart, '.') >= 0 {
		return nil, nil, nil, nil, oauth.ErrInvalidToken
	}
	// ... decode the three segments with b64 as today ...
	return header, payload, sig, token[:second], nil
}

Measure the change with a benchmark and report the actual before/after numbers rather than an estimate.

As per coding guidelines: "Never invent benchmark numbers; if a benchmark was not run, state that explicitly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/generic/generic.go` around lines 126 - 129, Update splitToken
to return the signing input as an additional value, using the existing token
subslice covering header.payload without allocating. In Verify, consume that
returned signing input and remove the second strings.Split and concatenation
before verifySignature. Add or update a benchmark to measure the allocation
change, and report actual results only if the benchmark is run.

Source: Coding guidelines

internal/oauth/presets/google.go (1)

32-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exported preset constructors return unexported types in internal/oauth/presets/google.go and internal/oauth/presets/stackit.go. Both constructors are exported but hand back a pointer to a package-private struct, so a caller in another package cannot name the returned type, cannot declare a variable of it, and cannot embed it. Both structs already satisfy oauth.Provider, which is the type server stores and oauth.Register accepts.

  • internal/oauth/presets/google.go#L32-L44: change the signature to func NewGoogle(cfg oauth.Config, logger log.Logger) (oauth.Provider, error) and keep the google struct unexported.
  • internal/oauth/presets/stackit.go#L33-L45: change the signature to func NewStackit(cfg oauth.Config, logger log.Logger) (oauth.Provider, error) and keep the stackit struct unexported.

Both preset test files call p.Config() and p.Verify(...), which the interface exposes, so the existing tests compile unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/presets/google.go` around lines 32 - 44, Exported preset
constructors currently expose unexported concrete return types. Update NewGoogle
in internal/oauth/presets/google.go:32-44 and NewStackit in
internal/oauth/presets/stackit.go:33-45 to return (oauth.Provider, error), while
keeping the google and stackit structs unexported and preserving their existing
construction logic.
internal/oauth/oauth.go (1)

60-75: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align IsJWT with its documented contract.

The comment at Line 72-74 does not match the code. "a.b" contains one dot, not two, and two dots always produce three segments, so the parenthetical is inverted.

The len(b) >= 5 guard also does not enforce non-empty segments. "..abc" has two dots and length 5, so IsJWT returns true even though the header and payload segments are empty. The unit test case "two dots empty head" uses "..c", which is rejected by the length check alone, so the empty-segment case is not covered.

There is no security impact: generic.splitToken rejects the empty segment later. The routing contract should still say what it does.

♻️ Proposed segment check
 func IsJWT(b []byte) bool {
-	dots := 0
-	for _, c := range b {
-		if c == '.' {
-			dots++
-		}
-	}
-	// At least one non-empty segment each side of two dots, so a bare ".." or
-	// "a.b" (three segments is impossible with two dots) cannot slip through.
-	return dots == 2 && len(b) >= 5
+	// Require three non-empty dot-separated segments. A bare "..", "..c" or
+	// "a.b" is therefore never routed to token verification.
+	first := bytes.IndexByte(b, '.')
+	if first <= 0 {
+		return false
+	}
+	rest := b[first+1:]
+	second := bytes.IndexByte(rest, '.')
+	if second <= 0 {
+		return false
+	}
+	tail := rest[second+1:]
+	return len(tail) > 0 && bytes.IndexByte(tail, '.') < 0
 }

This needs "bytes" in the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/oauth.go` around lines 60 - 75, Update IsJWT to validate
exactly three dot-separated, non-empty segments rather than relying on dots == 2
and len(b) >= 5; use the proposed bytes-based segment check and add the required
bytes import. Correct the surrounding comment and test the empty-header/payload
case such as "..c" or "..abc" so the documented contract matches the routing
behavior.
internal/oauth/README.md (1)

18-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use one line-break syntax in the flowchart labels.

Line 20 uses \n inside the node label. Line 21 and Line 22 use <br>. Mermaid does not treat \n as a line break in node text across renderers, so the first node can render with a literal backslash-n on GitHub.

📝 Proposed fix
-    A[AUTH command arrives] --> B{Does the password\nlook like a JWT?}
+    A[AUTH command arrives] --> B{"Does the password<br>look like a JWT?"}
     B -- "no (oauth.IsJWT false)" --> C[Password path:<br>bcrypt hash vs policy user]
     B -- "yes (2 dots, >= 5 bytes)" --> D[Token path]
     D --> E{oauth provider\nconfigured?}

Apply the same change to the E node on Line 23.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/README.md` around lines 18 - 31, Standardize the Mermaid
flowchart labels on the supported `<br>` line-break syntax: update the `A` and
`B` node labels that currently use `\n`, and ensure the `E` node uses the same
syntax. Preserve the existing label text and flowchart structure.
internal/resp/oauth_test.go (1)

97-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the three-argument AUTH form.

internal/resp/server.go:840 routes a token to the provider only when len(args) == 2. No test pins that rule. A regression that also accepts AUTH <user> <token> would pass silently and would change the session identity source. Add a case that sends the valid token with a username and asserts the rejection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/resp/oauth_test.go` around lines 97 - 111, Extend
TestRESPServer_OAuthDenyNoRoleMatch to cover the three-argument AUTH form by
sending the valid token with a username and asserting it is rejected, then
verify the connection remains unauthenticated. Keep the existing two-argument
denial case unchanged and use the same token/session setup.
internal/rbac/policy.go (1)

226-247: 🩺 Stability & Availability | 🔵 Trivial

Consider surfacing the verification error to the caller.

ResolveOAuthToken discards err from verify. The listeners then log every failure as invalid token. The oauth.Provider contract distinguishes ErrInvalidToken from transient errors, such as an unreachable identity provider. Operators cannot separate a forged token from an IdP outage in the audit log or in metrics.

Deny in both cases, but return the error so the callers can log or count it separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/rbac/policy.go` around lines 226 - 247, The ResolveOAuthToken
signature currently discards verification failures; update it and its callers to
return or propagate the verify error while still denying access. Preserve nil
results for invalid claims or unavailable policy, and ensure listeners can
distinguish ErrInvalidToken from transient identity-provider errors when logging
or counting failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ARCHITECTURE.md`:
- Line 83: Add OAuth/OIDC to the “Opt-In Features” table in ARCHITECTURE.md with
a row documenting the --oauth-provider and --oauth-issuer options, and mark the
feature as disabled by default. Keep the existing internal/oauth component entry
unchanged.

In `@cmd/example/oauth/main.go`:
- Around line 101-105: The stdin token-reading flow around scanner.Scan must
configure Scanner.Buffer for the supported protocol message size, preserve
successful tokens up to that limit, and return scanner.Err() when scanning fails
instead of always returning os.ErrNotExist. Add a regression test covering a
valid id_token larger than the default 64 KiB scanner limit.

In `@cmd/example/oauth/policy.yaml`:
- Around line 26-30: Align cmd/example/oauth/policy.yaml:26-30 with the
documented identities by configuring an `@saxy.dev` admin rule and an
`@tellstone.io` readonly rule; update cmd/example/oauth/policy.yaml:7-16 to
describe those domains and roles, and update cmd/example/oauth/main.go:58-59 to
assert the role granted by the aligned policy.

In `@internal/network/client.go`:
- Around line 217-224: Add explicit math.MaxUint16 bounds before payloadLen
computation in the authentication method containing the shown password-only
frame in internal/network/client.go (lines 217-224), returning a clear error
when len(password) exceeds the limit; apply the same validation in
internal/network/client_role.go (lines 151-161) for both username and password
before computing payloadLen, preventing uint16 length-field wrapping.

In `@internal/network/server.go`:
- Around line 677-686: The OAuth token verification calls in both AUTH worker
pools use an unbounded context. In internal/network/server.go:677-686, update
the callback passed to ResolveOAuthToken to create and defer-cancel a
context.WithTimeout around s.oauth.Verify; apply the same bounded timeout value
and context lifecycle in internal/resp/server.go:1000-1009. Keep the existing
session and username resolution behavior unchanged.
- Around line 612-622: Update the JWT routing condition in the binary
authentication flow to dispatch OAuth tokens only when the supplied username is
empty, matching the RESP listener’s token-only form. Preserve the existing
non-empty-username authentication path and JWT error response behavior.

In `@internal/oauth/generic/generic_test.go`:
- Around line 131-141: Update signJWT to support HS256 by adding the required
HMAC implementation and produce a valid signature, so disallowed-algorithm
coverage reaches the allowedAlgs check. Also update the “wrong signature” test
to use kid "key-1" with a different private key, ensuring verification reaches
verifySignature instead of failing on unknown kid.

In `@internal/oauth/generic/generic.go`:
- Around line 115-125: Add a throttled, single-flight refresh helper near
refreshJWKS, using a mutex, timestamp, and in-flight state to enforce a minimum
refresh interval and collapse concurrent calls without new dependencies. Update
the unknown-key path in the token-validation method to call
refreshJWKSThrottled; return oauth.ErrInvalidToken when cooldown prevents
refresh, while preserving refreshJWKS errors and the existing post-refresh key
lookup behavior.
- Around line 316-333: Update validateClaims to require a valid exp claim:
reject tokens when raw["exp"] is absent or num cannot parse it, and retain the
existing expiration check for present claims. Add a regression case to
TestVerifyRejectsInvalidToken that signs a payload with exp removed and verifies
validation fails.

In `@internal/oauth/presets/google_test.go`:
- Around line 51-56: In the test setup that encodes the Google JWKS response,
update the QF1008-triggering references in the map construction to use the
promoted rsa.PrivateKey fields directly: replace priv.PublicKey.E and
priv.PublicKey.N with priv.E and priv.N. Run task fmt and task check afterward.

In `@internal/oauth/presets/google.go`:
- Around line 48-57: Update google.Verify so the hosted-domain value is added to
claims["groups"] only when no groups claim already exists; preserve the existing
groups value otherwise. Add a google preset test covering a token containing
both hd and groups, asserting the original groups remain unchanged.

In `@server/server.go`:
- Around line 229-237: Update Server.initOAuth to handle an empty
cfg.GetOAuthClientID before constructing oauth.Config: require the client ID
when OAuth token authentication is enabled, or emit an explicit startup warning
that audience validation is disabled if empty IDs are intentionally supported.
- Around line 238-256: In initOAuth, validate s.policy before entering the OAuth
provider switch, returning the existing --oauth-provider requires --rbac-config
error immediately when it is nil. Remove the later duplicate policy check while
preserving provider construction and error handling for valid configurations.

---

Nitpick comments:
In `@internal/oauth/generic/generic.go`:
- Around line 187-201: Update Provider.getJSON to wrap resp.Body with
io.LimitReader before passing it to json.Decoder, using a sufficiently bounded
limit for discovery and JWKS responses. Add the required io import and preserve
the existing request, status validation, and decoding behavior.
- Around line 142-156: In Provider.refreshDiscovery, validate doc.Issuer against
the configured issuer after decoding the discovery document and before accepting
doc.JWKSURI; normalize trailing slashes consistently with the request URL and
return an error on mismatch. Preserve the existing missing-jwks_uri check and
refreshJWKS flow for a matching issuer.
- Around line 244-256: Update internal/oauth/generic/generic.go:244-256 in
parseJWK to confirm the declared Go toolchain supports the newer APIs, then
left-pad decoded EC coordinates to 32 bytes and construct the P-256 key with
ecdsa.ParseUncompressedPublicKey using the uncompressed point format, preserving
decode errors and validating curve membership. Update
internal/oauth/generic/generic_test.go:98-110 in jwksFromKeys to derive
coordinates from pk.Bytes() and slice the uncompressed point instead of
accessing deprecated pk.X and pk.Y.
- Around line 126-129: Update splitToken to return the signing input as an
additional value, using the existing token subslice covering header.payload
without allocating. In Verify, consume that returned signing input and remove
the second strings.Split and concatenation before verifySignature. Add or update
a benchmark to measure the allocation change, and report actual results only if
the benchmark is run.

In `@internal/oauth/oauth.go`:
- Around line 60-75: Update IsJWT to validate exactly three dot-separated,
non-empty segments rather than relying on dots == 2 and len(b) >= 5; use the
proposed bytes-based segment check and add the required bytes import. Correct
the surrounding comment and test the empty-header/payload case such as "..c" or
"..abc" so the documented contract matches the routing behavior.

In `@internal/oauth/presets/google.go`:
- Around line 32-44: Exported preset constructors currently expose unexported
concrete return types. Update NewGoogle in
internal/oauth/presets/google.go:32-44 and NewStackit in
internal/oauth/presets/stackit.go:33-45 to return (oauth.Provider, error), while
keeping the google and stackit structs unexported and preserving their existing
construction logic.

In `@internal/oauth/README.md`:
- Around line 18-31: Standardize the Mermaid flowchart labels on the supported
`<br>` line-break syntax: update the `A` and `B` node labels that currently use
`\n`, and ensure the `E` node uses the same syntax. Preserve the existing label
text and flowchart structure.

In `@internal/oauth/registry.go`:
- Around line 79-88: Update the Providers doc comment to say “Providers returns”
and replace sort.Strings with slices.Sort, updating the import from sort to
slices accordingly.

In `@internal/rbac/policy.go`:
- Around line 226-247: The ResolveOAuthToken signature currently discards
verification failures; update it and its callers to return or propagate the
verify error while still denying access. Preserve nil results for invalid claims
or unavailable policy, and ensure listeners can distinguish ErrInvalidToken from
transient identity-provider errors when logging or counting failures.

In `@internal/resp/oauth_test.go`:
- Around line 97-111: Extend TestRESPServer_OAuthDenyNoRoleMatch to cover the
three-argument AUTH form by sending the valid token with a username and
asserting it is rejected, then verify the connection remains unauthenticated.
Keep the existing two-argument denial case unchanged and use the same
token/session setup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d5f556e5-3183-4cfc-b986-a796b58c1da5

📥 Commits

Reviewing files that changed from the base of the PR and between d8b6431 and 9240bb6.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (41)
  • ARCHITECTURE.md
  • README.md
  • ROADMAP.md
  • cmd/example/client/main.go
  • cmd/example/oauth/main.go
  • cmd/example/oauth/policy.yaml
  • cmd/tellstone/main.go
  • config/config.go
  • go.mod
  • internal/metrics/metrics_test.go
  • internal/network/acl_test.go
  • internal/network/benchmark_tls_test.go
  • internal/network/client.go
  • internal/network/client_role.go
  • internal/network/oauth_test.go
  • internal/network/protocol_test.go
  • internal/network/role_test.go
  • internal/network/server.go
  • internal/network/server_test.go
  • internal/oauth/README.md
  • internal/oauth/generic/generic.go
  • internal/oauth/generic/generic_test.go
  • internal/oauth/oauth.go
  • internal/oauth/oauth_test.go
  • internal/oauth/presets/google.go
  • internal/oauth/presets/google_test.go
  • internal/oauth/presets/stackit.go
  • internal/oauth/presets/stackit_test.go
  • internal/oauth/registry.go
  • internal/oauth/registry_test.go
  • internal/rbac/config.go
  • internal/rbac/oauth_rules_test.go
  • internal/rbac/policy.go
  • internal/resp/acl_test.go
  • internal/resp/handshake_test.go
  • internal/resp/oauth_test.go
  • internal/resp/role_test.go
  • internal/resp/server.go
  • internal/resp/server_test.go
  • internal/resp/starttls_test.go
  • server/server.go

Comment thread ARCHITECTURE.md
Comment thread cmd/example/oauth/main.go
Comment thread cmd/example/oauth/policy.yaml
Comment thread internal/network/client.go
Comment thread internal/network/server.go
Comment thread internal/oauth/generic/generic.go
Comment thread internal/oauth/presets/google_test.go Outdated
Comment thread internal/oauth/presets/google.go
Comment thread server/server.go
Comment thread server/server.go Outdated
…blilty

Signed-off-by: Maximilian Hagen <git@saxy.dev>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/oauth/presets/google_test.go (1)

113-118: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Stop after a scope-length mismatch.

If cfg.Scopes contains fewer entries than want, Line 113 records the failure but the loop still indexes cfg.Scopes[i] and panics. Use t.Fatalf or return after the length check so the test reports the assertion failure directly.

💚 Proposed fix
 	if len(cfg.Scopes) != len(want) {
-		t.Errorf("Scopes = %v, want %v", cfg.Scopes, want)
+		t.Fatalf("Scopes = %v, want %v", cfg.Scopes, want)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/presets/google_test.go` around lines 113 - 118, Update the
scope-length assertion in the test around cfg.Scopes and want to stop execution
when their lengths differ, using t.Fatalf or an immediate return before the
comparison loop. Preserve the per-scope equality checks for matching-length
slices.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/network/server.go`:
- Around line 614-617: Update the JWT handling around dispatchOAuth so token
storage is obtained from a preallocated reusable OAuth job buffer only after
worker-queue admission succeeds, avoiding per-request allocation when the pool
is full. Propagate the buffer through the OAuth dispatch path and return it when
the job is rejected, preserving the existing AUTHERR behavior.

---

Outside diff comments:
In `@internal/oauth/presets/google_test.go`:
- Around line 113-118: Update the scope-length assertion in the test around
cfg.Scopes and want to stop execution when their lengths differ, using t.Fatalf
or an immediate return before the comparison loop. Preserve the per-scope
equality checks for matching-length slices.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 100e613f-b73c-4e12-8b83-3e9b7bb8d410

📥 Commits

Reviewing files that changed from the base of the PR and between 9240bb6 and a3d5b60.

📒 Files selected for processing (7)
  • internal/network/client.go
  • internal/network/client_role.go
  • internal/network/oauth_test.go
  • internal/network/server.go
  • internal/oauth/presets/google.go
  • internal/oauth/presets/google_test.go
  • server/server.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/oauth/presets/google.go

Comment thread internal/network/server.go Outdated
Signed-off-by: Maximilian Hagen <git@saxy.dev>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/network/server.go (2)

49-52: 🚀 Performance & Scalability | 🟠 Major

Make pooled token ownership explicit and allocation-free.

buf.Bytes() passes a non-owning slice to the asynchronous job. The authJob change does not show ownership of the *bytes.Buffer. Reusing the buffer before Verify finishes can overwrite job.password. Never releasing it after an accepted job also defeats pooling.

Carry the buffer owner through authJob. Release it exactly once after verification and on queue rejection. This path also uses make, interface{} boxing, and a growing bytes.Buffer, so it can allocate.

Verification commands
#!/bin/bash
set -euo pipefail

rg -n -C 8 'tokenPool|dispatchOAuth|type authJob|job\.password|oauth\.Verify' internal/network/server.go
rg -n 'AllocsPerRun|AllocsPerOp|Benchmark.*(Auth|OAuth)' internal/network -g '*_test.go'

As per coding guidelines, the internal/network request-handling hot path must not allocate, use make(), or use interface{} boxing, and allocation behavior must be verified with benchmarks. The previous review also required releasing the reusable buffer on rejected dispatches.

Also applies to: 592-597, 622-625, 685-694, 777-793

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/network/server.go` around lines 49 - 52, Update authJob and the
OAuth dispatch/verification flow around dispatchOAuth and oauth.Verify to carry
ownership of the pooled *bytes.Buffer alongside the non-owning token slice.
Release that buffer exactly once after verification, including every
accepted-job completion path and queue rejection, and ensure no reuse occurs
before verification finishes. Remove hot-path make calls, interface{} boxing,
and growing-buffer allocations, then add or update internal/network benchmarks
to verify zero allocations.

Source: Coding guidelines


179-182: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject OAuth configuration without an RBAC policy store.

NewServer accepts oauth != nil with policy == nil, handleAuthMessage bypasses authentication when both requirePassHash and policy are nil, startup leaves connections authenticated with policy == nil, and the OAuth worker path dereferences s.policy. Either reject provider-only startup, include s.oauth == nil in the no-auth fast path, and update startup auth state together, and add a regression test for provider-only configuration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/network/server.go` around lines 179 - 182, Update NewServer’s
OAuth/RBAC validation to reject any provider-only configuration where provider
is non-nil and policy is nil, preventing startup with an OAuth worker that
cannot access s.policy. Preserve valid password-only, policy-only, and combined
configurations, and add a regression test covering provider-only startup
rejection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/network/server.go`:
- Around line 49-52: Update authJob and the OAuth dispatch/verification flow
around dispatchOAuth and oauth.Verify to carry ownership of the pooled
*bytes.Buffer alongside the non-owning token slice. Release that buffer exactly
once after verification, including every accepted-job completion path and queue
rejection, and ensure no reuse occurs before verification finishes. Remove
hot-path make calls, interface{} boxing, and growing-buffer allocations, then
add or update internal/network benchmarks to verify zero allocations.
- Around line 179-182: Update NewServer’s OAuth/RBAC validation to reject any
provider-only configuration where provider is non-nil and policy is nil,
preventing startup with an OAuth worker that cannot access s.policy. Preserve
valid password-only, policy-only, and combined configurations, and add a
regression test covering provider-only startup rejection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4efb4cd1-3ee4-45ff-a1cd-830f62c5b8f1

📥 Commits

Reviewing files that changed from the base of the PR and between a3d5b60 and 7f891cd.

📒 Files selected for processing (2)
  • ARCHITECTURE.md
  • internal/network/server.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • ARCHITECTURE.md

…le-flight

Signed-off-by: Maximilian Hagen <git@saxy.dev>
@Saxy
Saxy merged commit ddb7762 into main Aug 9, 2026
9 checks passed
@Saxy
Saxy deleted the feat/oauth branch August 9, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Oauth Claim Mapping for RBAC System OIDC/OAuth2 Integration for SSO

1 participant