Skip to content

Add OAuth2/OpenID Connect authorization server#957

Open
gearnode wants to merge 7 commits intomainfrom
gearnode/oauth2-openid-server
Open

Add OAuth2/OpenID Connect authorization server#957
gearnode wants to merge 7 commits intomainfrom
gearnode/oauth2-openid-server

Conversation

@gearnode
Copy link
Copy Markdown
Contributor

@gearnode gearnode commented Mar 30, 2026

Summary

  • Implements a full OAuth2 2.0 and OpenID Connect 1.0 authorization server with authorization code flow (PKCE), refresh token rotation, device authorization grant, dynamic client registration, token introspection, and revocation
  • Adds database schema (6 tables), coredata layer, oauth2server service package, HTTP handlers with CSRF bypasses for token endpoints, OIDC discovery (/.well-known/openid-configuration), and JWKS publishing
  • Integrates into the existing IAM service lifecycle with graceful shutdown and crash propagation

Test plan

  • Verify OIDC discovery endpoint returns correct metadata at /.well-known/openid-configuration
  • Test authorization code flow with PKCE end-to-end
  • Test device authorization grant flow
  • Test refresh token rotation and replay detection
  • Test token introspection and revocation with client authentication
  • Verify JWKS endpoint serves the correct public key

Summary by cubic

Adds a first‑party OAuth 2.0 and OpenID Connect 1.0 authorization server integrated with IAM and the HTTP API. It issues RSA‑signed JWTs with round‑robin active‑key signing, publishes JWKS and discovery/metadata, supports authorization code + PKCE, device code (with Console activation UI and CLI login), and refresh rotation with replay detection.

  • New Features

    • New oauth2server with RSA JWTs and active‑key rotation via pkg/crypto/jose; JWKS at /connect/v1/oauth2/jwks, OIDC discovery at /.well-known/openid-configuration, and OAuth 2.0 AS metadata at /.well-known/oauth-authorization-server.
    • Endpoints under /connect/v1/oauth2: token, device, userinfo, introspect, revoke; session‑based authorize + consent UI. Consent persists redirect_uri, code_challenge(+method), nonce, state, approved, and session_id.
    • Device authorization grant end‑to‑end: Console device activation page and GraphQL authorizeDevice mutation; Probo CLI login now uses device flow with a pre‑provisioned client.
    • OAuth2 access token middleware for the API to accept Bearer tokens.
    • Authorization code + PKCE; refresh token rotation with replay detection and atomic updates; background GC for codes/tokens/consents.
    • Validated absolute URIs via pkg/uri and open‑redirect checks; scopes parsed in the HTTP layer and passed as typed coredata.OAuth2Scopes.
    • Centralized OAuth2 error handling with a typed OAuth2Error; only server_error responses are logged.
    • CSRF bypass for cross‑origin POSTs to token, introspect, revoke, and device.
  • Migration

    • Run the new DB migrations.
    • Configure signing keys in auth.oauth2-server.signing-keys (set kid and active; at least one key must be active). Active keys sign tokens round‑robin; all keys appear in JWKS. Restart.
    • Optionally set auth.oauth2-server.access-token-duration, refresh-token-duration, authorization-code-duration, and device-code-duration.
    • Verify /.well-known/openid-configuration, /.well-known/oauth-authorization-server, and /connect/v1/oauth2/jwks return the expected metadata and keys.

Written for commit 8f218ab. Summary will update on new commits.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

9 issues found across 43 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/iam/oauth2server/service.go">

<violation number="1" location="pkg/iam/oauth2server/service.go:480">
P1: Custom agent: **Avoid Logging Sensitive Information**

Remove or redact the identity_id from this log message to avoid logging sensitive user identifiers.</violation>

<violation number="2" location="pkg/iam/oauth2server/service.go:517">
P1: Refresh token rotation is not atomic: the old token is revoked in one transaction, then new tokens are created in separate connections. If `CreateRefreshToken` fails (or the process crashes) after the old token is already revoked, the client loses its session with no way to recover except re-authentication. Consider moving the new token creation into the same transaction that revokes the old token.</violation>

<violation number="3" location="pkg/iam/oauth2server/service.go:684">
P1: Race condition: status check and token issuance happen outside the transaction that locks the device code row. Two concurrent polls for an authorized device code will both pass the status check and issue duplicate token sets. Move the status/expiry checks and device code deletion (or a status transition to a "consumed" state) inside the `WithTx` block so that only one poller can issue tokens.</violation>
</file>

<file name="pkg/iam/service.go">

<violation number="1" location="pkg/iam/service.go:188">
P2: Validate OAuth2ServerSigningKey before constructing the OAuth2 server service; it is dereferenced in JWKS/ID token signing and will panic if nil.</violation>
</file>

<file name="pkg/iam/oauth2server/user_code.go">

<violation number="1" location="pkg/iam/oauth2server/user_code.go:39">
P2: GenerateUserCode returns an unformatted 8‑character code despite the comment promising XXXX-XXXX. Either format the return value or adjust the docs; as written, callers expecting the documented format will render the code incorrectly.</violation>
</file>

<file name="pkg/coredata/oauth2_scope.go">

<violation number="1" location="pkg/coredata/oauth2_scope.go:80">
P2: Validate each parsed scope in OAuth2Scopes.UnmarshalText; otherwise invalid scope strings are accepted silently.</violation>
</file>

<file name="pkg/coredata/oauth2_consent.go">

<violation number="1" location="pkg/coredata/oauth2_consent.go:53">
P1: Coredata methods here omit the Scoper/tenant_id filter required for tenant isolation. This allows cross-tenant reads/writes of OAuth2 consent records.</violation>
</file>

<file name="pkg/server/api/connect/v1/oauth2_handler.go">

<violation number="1" location="pkg/server/api/connect/v1/oauth2_handler.go:299">
P0: The `redirect_uri` from the consent form POST body is not re-validated against the client's registered redirect URIs. An attacker can modify the hidden form field to steal the authorization code via an open redirect. Load the client and validate `redirect_uri` against `client.RedirectURIs` before issuing the redirect, just as `AuthorizeHandler` does.</violation>
</file>

<file name="pkg/iam/oauth2server/gc.go">

<violation number="1" location="pkg/iam/oauth2server/gc.go:74">
P2: Validate gc.interval before calling time.NewTicker; zero/negative durations will panic at runtime.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/iam/oauth2server/errors.go">

<violation number="1" location="pkg/iam/oauth2server/errors.go:33">
P1: `ErrInvalidRedirectURI` is not mapped in `OAuth2ErrorCode`, so it will be reported as `server_error` instead of a client error.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +36 to +37
SigningKeyFile string `json:"signing-key-file"`
SigningKID string `json:"signing-kid"`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should be an array no, so we can rotate them if needed?

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/server/api/connect/v1/oauth2_handler.go">

<violation number="1" location="pkg/server/api/connect/v1/oauth2_handler.go:223">
P1: Nil pointer dereference: `session.ID` is accessed without a nil check, but `session` can be nil. The code already nil-checks `session` for `authTime` but not for `SessionID`. If this handler is ever reached via a non-session auth path (e.g., API key), this will panic.</violation>
</file>

<file name="pkg/coredata/oauth2_consent.go">

<violation number="1" location="pkg/coredata/oauth2_consent.go:132">
P2: `CountByIdentityID` is missing the `AND approved = TRUE` filter that was added to `LoadByIdentityID`. The count will include non-approved consents that the load query excludes, producing incorrect pagination totals.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

r.Context(),
&oauth2server.AuthorizeRequest{
IdentityID: identity.ID,
SessionID: session.ID,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 31, 2026

Choose a reason for hiding this comment

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

P1: Nil pointer dereference: session.ID is accessed without a nil check, but session can be nil. The code already nil-checks session for authTime but not for SessionID. If this handler is ever reached via a non-session auth path (e.g., API key), this will panic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pkg/server/api/connect/v1/oauth2_handler.go, line 223:

<comment>Nil pointer dereference: `session.ID` is accessed without a nil check, but `session` can be nil. The code already nil-checks `session` for `authTime` but not for `SessionID`. If this handler is ever reached via a non-session auth path (e.g., API key), this will panic.</comment>

<file context>
@@ -244,42 +218,44 @@ func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request)
-		client,
 		&oauth2server.AuthorizeRequest{
 			IdentityID:          identity.ID,
+			SessionID:           session.ID,
 			ResponseType:        q.Get("response_type"),
 			ClientID:            clientID,
</file context>
Fix with Cubic

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/iam/oauth2server/service.go">

<violation number="1" location="pkg/iam/oauth2server/service.go:407">
P1: Directly indexing `s.signingKeys[0]` can panic when no signing keys are configured; validate non-empty keys before use.</violation>
</file>

<file name="pkg/probod/probod.go">

<violation number="1" location="pkg/probod/probod.go:441">
P2: Validate that at least one OAuth2 signing key is configured before building the IAM service; otherwise OpenID token signing will panic on `s.signingKeys[0]`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Scopes: scopes,
AccessTokenID: accessTokenID,
CreatedAt: now,
ExpiresAt: now.Add(30 * 24 * time.Hour),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should be service configuration.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/iam/oauth2server/service.go">

<violation number="1" location="pkg/iam/oauth2server/service.go:113">
P1: Handle the case where no signing keys are marked active; otherwise signingKey() will panic at runtime when len(activeSigningIdx)==0.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +113 to +117
for i, k := range cfg.SigningKeys {
if k.Active {
activeIdx = append(activeIdx, i)
}
}
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 31, 2026

Choose a reason for hiding this comment

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

P1: Handle the case where no signing keys are marked active; otherwise signingKey() will panic at runtime when len(activeSigningIdx)==0.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pkg/iam/oauth2server/service.go, line 113:

<comment>Handle the case where no signing keys are marked active; otherwise signingKey() will panic at runtime when len(activeSigningIdx)==0.</comment>

<file context>
@@ -106,18 +109,33 @@ type (
 
 func NewService(pgClient *pg.Client, cfg Config) *Service {
+	var activeIdx []int
+	for i, k := range cfg.SigningKeys {
+		if k.Active {
+			activeIdx = append(activeIdx, i)
</file context>
Suggested change
for i, k := range cfg.SigningKeys {
if k.Active {
activeIdx = append(activeIdx, i)
}
}
var activeIdx []int
for i, k := range cfg.SigningKeys {
if k.Active {
activeIdx = append(activeIdx, i)
}
}
if len(activeIdx) == 0 && len(cfg.SigningKeys) > 0 {
activeIdx = append(activeIdx, 0)
}
Fix with Cubic

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/server/api/connect/v1/oauth2_error.go">

<violation number="1" location="pkg/server/api/connect/v1/oauth2_error.go:86">
P2: `server_error` is missing from the redirectable errors list. RFC 6749 §4.1.2.1 explicitly defines `server_error` as a redirectable authorization endpoint error, noting it exists precisely because a 500 cannot be returned via redirect. Without it, authorization-endpoint server errors render as JSON instead of redirecting back to the client.</violation>
</file>

<file name="pkg/server/api/connect/v1/oauth2_handler.go">

<violation number="1" location="pkg/server/api/connect/v1/oauth2_handler.go:102">
P2: Invalid client responses no longer include a `WWW-Authenticate` challenge header when Basic client auth fails.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/iam/oauth2server/service.go">

<violation number="1" location="pkg/iam/oauth2server/service.go:675">
P1: Avoid passing raw internal errors to `ErrServerError.WithDescription`; it leaks internal details via `error_description`.</violation>

<violation number="2" location="pkg/iam/oauth2server/service.go:1156">
P1: Do not expose `err.Error()` in OAuth2 server errors; return a sanitized server_error description instead.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/server/api/connect/v1/oauth2_handler.go">

<violation number="1" location="pkg/server/api/connect/v1/oauth2_handler.go:320">
P2: Handle `DecodeForm` errors in `DeviceVerifySubmit`; the current code swallows invalid form data and continues with an empty `user_code`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@gearnode gearnode force-pushed the gearnode/oauth2-openid-server branch from 06999a6 to 898cb63 Compare April 6, 2026 09:23
}
u.RawQuery = q.Encode()

http.Redirect(w, r, u.String(), http.StatusFound)

Check warning

Code scanning / CodeQL

Open URL redirect Medium

This path to an untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 7 days ago

In general, to fix open redirect issues you must not redirect directly to user-controlled URLs. Instead, either (a) restrict redirects to a known-safe set (e.g., registered client redirect URIs) or (b) enforce that the redirect target is local (same host) and relative. Since we cannot see or change how client redirect URIs are stored or validated in other parts of the system, the most robust, self-contained fix here is to ensure that redirectWithError only redirects to URLs that are local (no scheme/host) and do not perform an external redirect.

The best targeted fix in this code is to add validation in redirectWithError before calling http.Redirect. We can normalize the redirectURI string to mitigate backslash ambiguities, parse it, and then ensure that u.Scheme and u.Host are empty (so it’s a relative URL) and that it doesn’t contain suspicious patterns such as protocol-relative URLs (//example.com) in the path. If the URL fails validation, instead of redirecting we should fall back to rendering a generic OAuth2 error response (or at least a non-redirect HTTP error) to avoid sending the user to an attacker-provided URL.

Concretely:

  • In pkg/server/api/connect/v1/oauth2_error.go, inside redirectWithError:
    • Before url.Parse, replace backslashes with forward slashes in redirectURI.
    • Parse the URL into u.
    • After parsing and before modifying the query, check that u.Scheme == "" and u.Host == "".
    • Optionally, ensure u.Path does not start with // (which could be interpreted as protocol-relative).
    • If validation fails, log or handle an internal error by rendering an error response (consistent with existing handling).
  • This only requires importing "strings" in that file; all other imports can remain unchanged.
  • No changes are required to the types in types/oauth2.go or to AuthorizeHandler; they can continue passing redirectURI as-is, with redirectWithError acting as the enforcement point.
Suggested changeset 1
pkg/server/api/connect/v1/oauth2_error.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/server/api/connect/v1/oauth2_error.go b/pkg/server/api/connect/v1/oauth2_error.go
--- a/pkg/server/api/connect/v1/oauth2_error.go
+++ b/pkg/server/api/connect/v1/oauth2_error.go
@@ -18,6 +18,7 @@
 	"errors"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"go.gearno.de/kit/httpserver"
 	"go.gearno.de/kit/log"
@@ -76,12 +77,24 @@
 }
 
 func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, state string, err error) {
-	u, parseErr := url.Parse(redirectURI)
+	// Normalize backslashes to forward slashes before parsing to avoid
+	// browser-dependent interpretation of the URL.
+	normalized := strings.ReplaceAll(redirectURI, "\\", "/")
+
+	u, parseErr := url.Parse(normalized)
 	if parseErr != nil {
 		httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
 		return
 	}
 
+	// Ensure that we only redirect to a local (relative) URL. Reject any URL
+	// that specifies a scheme or host, or that starts with '//' which some
+	// browsers may treat as a protocol-relative URL.
+	if u.Scheme != "" || u.Host != "" || strings.HasPrefix(u.Path, "//") {
+		httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid redirect URI"))
+		return
+	}
+
 	oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
 	if !ok {
 		httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
EOF
@@ -18,6 +18,7 @@
"errors"
"net/http"
"net/url"
"strings"

"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
@@ -76,12 +77,24 @@
}

func redirectWithError(w http.ResponseWriter, r *http.Request, redirectURI, state string, err error) {
u, parseErr := url.Parse(redirectURI)
// Normalize backslashes to forward slashes before parsing to avoid
// browser-dependent interpretation of the URL.
normalized := strings.ReplaceAll(redirectURI, "\\", "/")

u, parseErr := url.Parse(normalized)
if parseErr != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
return
}

// Ensure that we only redirect to a local (relative) URL. Reject any URL
// that specifies a scheme or host, or that starts with '//' which some
// browsers may treat as a protocol-relative URL.
if u.Scheme != "" || u.Host != "" || strings.HasPrefix(u.Path, "//") {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid redirect URI"))
return
}

oauthErr, ok := errors.AsType[*oauth2server.OAuth2Error](err)
if !ok {
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
Copilot is powered by AI and may make mistakes. Always verify output.
}
u.RawQuery = q.Encode()

http.Redirect(w, r, u.String(), http.StatusFound)

Check warning

Code scanning / CodeQL

Open URL redirect Medium

This path to an untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 6 days ago

In general, to fix an open redirect you should not redirect directly to user-provided URLs. Instead, either (a) map user-provided identifiers to a server-maintained whitelist of redirect targets, or (b) constrain the redirect to same-origin / local paths (e.g., only relative URLs or URLs with your expected host and scheme), and reject any others.

For this codebase, the minimal change that preserves existing behavior while adding protection is to validate redirectURI inside redirectWithCode before calling http.Redirect. We can implement a small helper, isSafeRedirectURL, in oauth2_handler.go that:

  1. Replaces any backslashes in the candidate URL with forward slashes (to avoid browser backslash quirks).
  2. Parses the URL with url.Parse.
  3. Accepts the URL only if:
    • It is relative (no scheme and no host), or
    • It is absolute but same-origin as the current request (scheme, host, and port equal to r.URL.Scheme/r.Host or, more robustly, use r.URL and r.Host).

If validation fails, instead of redirecting to the untrusted URL, we should respond with a safe error response. Since this function is used in the OAuth2 authorization flow, the safest backward-compatible behavior is to send a 400 Bad Request and a simple JSON error message (or potentially call an existing error renderer). However, to keep changes tightly scoped and avoid dependencies on other handlers, I will return a 400 with a short plaintext or JSON-style error from within redirectWithCode. This changes behavior only when the redirect URI is unsafe; legitimate, registered redirect URIs (which should be relative or same-origin, or the system can be configured accordingly) will not be affected.

Concretely:

  • In pkg/server/api/connect/v1/oauth2_handler.go:
    • Add a helper isSafeRedirectURL(r *http.Request, rawURL string) (string, bool) that normalizes and validates the redirect URL, returning the cleaned URL string if it is acceptable.
    • Update redirectWithCode to:
      • Call isSafeRedirectURL.
      • If invalid, send http.Error(w, "invalid redirect_uri", http.StatusBadRequest) and return.
      • If valid, build the query (code, state) on the parsed URL and redirect as before.

No changes are required in pkg/server/api/connect/v1/types/oauth2.go; that file just copies the query parameter and doesn’t perform redirect logic.


Suggested changeset 1
pkg/server/api/connect/v1/oauth2_handler.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go
--- a/pkg/server/api/connect/v1/oauth2_handler.go
+++ b/pkg/server/api/connect/v1/oauth2_handler.go
@@ -23,6 +23,7 @@
 	"html/template"
 	"net/http"
 	"net/url"
+	"strings"
 	"time"
 
 	"go.gearno.de/kit/httpserver"
@@ -655,8 +656,49 @@
 	}
 }
 
+// isSafeRedirectURL validates a redirect URL to prevent open redirects.
+// It allows relative URLs (no scheme/host) and absolute URLs that are
+// same-origin with the current request.
+func isSafeRedirectURL(r *http.Request, rawURL string) (string, bool) {
+	if rawURL == "" {
+		return "", false
+	}
+
+	// Normalize backslashes to forward slashes to avoid browser quirks.
+	rawURL = strings.ReplaceAll(rawURL, "\\", "/")
+
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		return "", false
+	}
+
+	// Allow relative redirects (no scheme and no host).
+	if u.Scheme == "" && u.Host == "" {
+		return u.String(), true
+	}
+
+	// For absolute URLs, require same host as the current request.
+	// r.Host is the authority part (host[:port]).
+	if u.Host != "" && u.Host == r.Host {
+		return u.String(), true
+	}
+
+	return "", false
+}
+
 func redirectWithCode(w http.ResponseWriter, r *http.Request, redirectURI, code, state string) {
-	u, _ := url.Parse(redirectURI)
+	safeRedirect, ok := isSafeRedirectURL(r, redirectURI)
+	if !ok {
+		http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
+		return
+	}
+
+	u, err := url.Parse(safeRedirect)
+	if err != nil {
+		http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
+		return
+	}
+
 	q := u.Query()
 	q.Set("code", code)
 	if state != "" {
EOF
@@ -23,6 +23,7 @@
"html/template"
"net/http"
"net/url"
"strings"
"time"

"go.gearno.de/kit/httpserver"
@@ -655,8 +656,49 @@
}
}

// isSafeRedirectURL validates a redirect URL to prevent open redirects.
// It allows relative URLs (no scheme/host) and absolute URLs that are
// same-origin with the current request.
func isSafeRedirectURL(r *http.Request, rawURL string) (string, bool) {
if rawURL == "" {
return "", false
}

// Normalize backslashes to forward slashes to avoid browser quirks.
rawURL = strings.ReplaceAll(rawURL, "\\", "/")

u, err := url.Parse(rawURL)
if err != nil {
return "", false
}

// Allow relative redirects (no scheme and no host).
if u.Scheme == "" && u.Host == "" {
return u.String(), true
}

// For absolute URLs, require same host as the current request.
// r.Host is the authority part (host[:port]).
if u.Host != "" && u.Host == r.Host {
return u.String(), true
}

return "", false
}

func redirectWithCode(w http.ResponseWriter, r *http.Request, redirectURI, code, state string) {
u, _ := url.Parse(redirectURI)
safeRedirect, ok := isSafeRedirectURL(r, redirectURI)
if !ok {
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
return
}

u, err := url.Parse(safeRedirect)
if err != nil {
http.Error(w, "invalid redirect_uri", http.StatusBadRequest)
return
}

q := u.Query()
q.Set("code", code)
if state != "" {
Copilot is powered by AI and may make mistakes. Always verify output.
gearnode added 2 commits April 6, 2026 17:49
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
@gearnode gearnode force-pushed the gearnode/oauth2-openid-server branch from 898cb63 to 48b4b5c Compare April 7, 2026 16:45
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai bot commented Apr 13, 2026

You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment @cubic-dev-ai review.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
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.

2 participants