Skip to content

feat(auth): add generic OpenID Connect provider for SSO login - #395

Open
dexion wants to merge 1 commit into
vxcontrol:mainfrom
dexion:feat/generic-oidc-sso
Open

feat(auth): add generic OpenID Connect provider for SSO login#395
dexion wants to merge 1 commit into
vxcontrol:mainfrom
dexion:feat/generic-oidc-sso

Conversation

@dexion

@dexion dexion commented Aug 25, 2026

Copy link
Copy Markdown

Problem

PentAGI can delegate login to Google or GitHub. A self-hosted installation sitting behind a corporate identity provider — Keycloak, Authentik, Okta, Entra ID, Auth0 — has no way to use it, even though all of them speak plain OpenID Connect and OAuthClient is already an abstraction over a provider.

Writing the provider surfaced a smaller problem worth fixing along the way. The authorization request was built for Google and applied to everyone:

authOpts := []oauth2.AuthCodeOption{
    oauth2.SetAuthURLParam("nonce", nonce),
    oauth2.SetAuthURLParam("response_mode", "form_post"),
    oauth2.SetAuthURLParam("response_type", "code id_token"),
}

and the SameSite mode of the temporary state/nonce cookies was decided by comparing the provider name to the string "google" in two separate places. GitHub tolerates the extra parameters, but a standards-compliant OIDC provider reads response_type=code id_token as a hybrid flow request and refuses it unless the client may use the implicit flow. Keycloak, with a normal confidential client, answers exactly this:

Client is not allowed to initiate browser login with given response_type.
Implicit flow is disabled for the client.

What changed

Both decisions now belong to the provider:

  • AuthCodeOptions(nonce) returns the parameters a provider expects. The default is a plain authorization code flow with a nonce. The new WithFormPostCallback() option adds the form_post pair and is applied to the Google client, so its behaviour is unchanged.
  • CallbackSameSite() reports the SameSite mode the callback needs — None for a cross-site form POST, Lax otherwise. This replaces both provider == "google" comparisons, and the callback handler now clears the cookies with the same mode they were issued with instead of deciding again.

The new provider (oidc) configures itself from the issuer's discovery document, verifies the ID token signature and nonce, and resolves the email from the ID token, falling back to the UserInfo endpoint for providers that do not put an email there. Discovery runs once at startup with a ten second deadline: an unreachable identity provider is logged and skipped rather than blocking the server from starting.

Configuration

PUBLIC_URL=https://pentagi.example.com
OAUTH_OIDC_ISSUER=https://keycloak.example.com/realms/pentagi
OAUTH_OIDC_CLIENT_ID=pentagi
OAUTH_OIDC_CLIENT_SECRET=your_client_secret
# optional, defaults to openid,email,profile
OAUTH_OIDC_SCOPES=openid,email,profile

The client is registered in the identity provider with the standard authorization code flow and the redirect URI ${PUBLIC_URL}/api/v1/auth/login-callback — the same callback the existing providers use. The provider reaches the frontend through the existing /info providers list and renders on the login screen as Continue with SSO.

Accounts are matched by the email claim and created on first login with the regular User role, exactly as with Google and GitHub, so administrators are still promoted explicitly. PentAGI does not filter who may log in, which is noted in the docs together with the advice to bind the client to a group or policy on the identity provider side.

Verification

Verified end to end on a local stand — PentAGI built from this branch, a real Keycloak and a real Authentik, browser driven through the full flow.

Keycloak 26.0, confidential client, implicit flow explicitly disabled:

  • /api/v1/info advertises providers: ["oidc"]
  • the authorization request is response_type=code, code_challenge_method=S256, scope=openid email profile, with a nonce
  • login as sso-user lands an authenticated session and redirects to the return URI
  • the account page shows the SSO badge and the email taken from the ID token

Authentik 2025.8, confidential OAuth2 provider created through its API:

  • discovery against http://…/application/o/pentagi/ succeeds at startup
  • the same authorization code flow with PKCE completes
  • login as ak-user creates the account and lands an authenticated session

Resulting users, both provisioned by the provider under test:

 id |         mail         | type  | provider | role
----+----------------------+-------+----------+------
  1 | admin@pentagi.com    | local |          | Admin
  2 | sso-user@stand.local | oauth | oidc     | User
  3 | ak-user@stand.local  | oauth | oidc     | User

The "before" behaviour is reproducible against the same Keycloak client: a request carrying response_type=code id_token&response_mode=form_post is refused with the implicit-flow error quoted above, while response_type=code renders the login page.

Compatibility

  • No behaviour change for existing installations: Google keeps form_post + SameSite=None, GitHub keeps its GET callback. GitHub no longer receives the two parameters it used to ignore.
  • No new dependency — github.com/coreos/go-oidc/v3 is already used by the Google provider.
  • The provider stays absent unless issuer, client ID and client secret are all set.

Checks

go build ./...                                     ok
go vet ./pkg/server/... ./pkg/config/...           ok
go test ./pkg/server/oauth/... ./pkg/server/services/...
ok  pentagi/pkg/server/oauth
ok  pentagi/pkg/server/services

pnpm exec tsc -b                                   No errors found
pnpm exec vitest run (touched suites)              PASS (17) FAIL (0)
pnpm exec eslint --max-warnings 0 (touched files)  No issues found

New tests cover the authorization parameters and cookie mode of both provider flavours, OIDC discovery with default and custom scopes, the unreachable-issuer path, and the login screen rendering only the providers the server advertises.

@dexion
dexion marked this pull request as draft August 25, 2026 16:09
@dexion dexion changed the title feat(auth): add generic OpenID Connect provider for SSO login WIP: feat(auth): add generic OpenID Connect provider for SSO login Aug 25, 2026
@dexion
dexion force-pushed the feat/generic-oidc-sso branch from 1f148a1 to 5fdb3f0 Compare August 25, 2026 16:50
@dexion dexion changed the title WIP: feat(auth): add generic OpenID Connect provider for SSO login feat(auth): add generic OpenID Connect provider for SSO login Aug 25, 2026
@dexion
dexion marked this pull request as ready for review August 25, 2026 16:51
PentAGI can delegate login to Google or GitHub. A self-hosted installation
sitting behind a corporate identity provider — Keycloak, Authentik, Okta,
Entra ID, Auth0 — has no way to use it, even though all of them speak plain
OpenID Connect and OAuthClient is already an abstraction over a provider.

Writing the provider surfaced a smaller problem worth fixing along the way. The
authorization request was built for Google and applied to everyone: every
provider received response_mode=form_post and response_type=code id_token, and
the SameSite mode of the temporary state and nonce cookies was decided by
comparing the provider name to the string "google" in two separate places.
GitHub tolerates the extra parameters, but a standards-compliant OIDC provider
reads response_type=code id_token as a hybrid flow request and refuses it unless
the client is allowed to use the implicit flow. Keycloak answers such a request
with "Client is not allowed to initiate browser login with given response_type.
Implicit flow is disabled for the client."

Both decisions now belong to the provider:

- AuthCodeOptions(nonce) returns the parameters a provider expects. The default
  is a plain authorization code flow with a nonce; the new WithFormPostCallback()
  option adds the form_post pair and is applied to the Google client, so its
  behaviour is unchanged.
- CallbackSameSite() reports the SameSite mode the callback needs, replacing the
  two provider == "google" comparisons in the auth service. The callback handler
  now clears the cookies with the same mode they were issued with instead of
  deciding again.

The new provider configures itself from the issuer's discovery document,
verifies the ID token signature and nonce, and resolves the email from the ID
token, falling back to the UserInfo endpoint for providers that do not put an
email there. Discovery runs once at startup with a ten second deadline: an
unreachable identity provider is logged and skipped rather than blocking the
server from starting.

Configuration follows the existing providers — OAUTH_OIDC_ISSUER,
OAUTH_OIDC_CLIENT_ID, OAUTH_OIDC_CLIENT_SECRET and optional OAUTH_OIDC_SCOPES,
defaulting to openid,email,profile. The provider reaches the frontend through
the existing /info providers list and renders on the login screen as "Continue
with SSO". Accounts are matched by email and created with the regular User role,
exactly as with the existing providers.

Verified end to end against Keycloak 26 and Authentik 2025.8: both complete the
authorization code flow with PKCE, create the user on first login and land an
authenticated session.
@dexion
dexion force-pushed the feat/generic-oidc-sso branch from 5fdb3f0 to db1003c Compare August 25, 2026 16:59
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.

1 participant