feat(oidc): Initial OpenID Connect Support#1091
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request lays the groundwork for integrating OpenID Connect (OIDC) into the application. It provides the necessary infrastructure for users to authenticate using external identity providers, enhancing the system's flexibility and security by supporting modern authentication standards. The changes span across database schema, backend business logic, API endpoints, and frontend user interface components to deliver a seamless OIDC login experience. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces initial support for OpenID Connect (OIDC), which is a significant feature for authentication. The changes are comprehensive, touching both the Go backend and the TypeScript/React frontend. This includes new API endpoints, business logic for the OIDC flow, database schema changes for OIDC identities, and UI components for the sign-in page. The overall implementation is solid, but I have identified a few areas for improvement related to frontend type safety, a potential bug in the backend redirect logic, and a minor security concern in the user provisioning process. My review comments provide specific details and suggestions for these points.
|
感谢帮忙支持 SSO 功能。 |
provider 的 icon 和 样式稍后我这里添加下配置,打算支持 目前先测通标准OIDC,各种知名provider其实就是把认证的端点提前写好,如果之后有需要也可以预置 |
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…default - Add per-code mutex via sync.Map to serialize ExchangeCode redemption, preventing concurrent requests from both passing Get before Delete - Delete exchange code immediately after Get (before user lookup) - Normalize SyncRoleStrategy to 'always' in the no-DB-roles branch, fixing stale roles not being cleared when users are removed from all IdP groups
Ensure that when CaseSensitive is false, the match pattern is also lowercased before comparison, matching the behavior of group values in extractGroups.
| if errorDesc != "" { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": c.Query("error_description")}) | ||
| return | ||
| } |
There was a problem hiding this comment.
IdP error returns raw JSON instead of redirecting to the frontend
When the identity provider rejects the auth request (e.g., the user clicks "Cancel" on the consent screen), the IdP redirects back to this callback URL with ?error=access_denied&error_description=.... The current code responds with a raw application/json 400 response directly to the browser, so the user sees a JSON blob instead of being sent back to /sign-in. The browser never reaches the frontend idp-callback route that parses error and error_description URL params.
All other failure paths already redirect to the frontend (lines 160–163), so this case should follow the same pattern:
if errorDesc != "" {
baseURL := h.getBaseURL(c)
c.Redirect(http.StatusFound, fmt.Sprintf(
"%s/oauth/oidc/idp-callback?error=%s&error_description=%s",
baseURL,
url.QueryEscape(errorDesc),
url.QueryEscape(c.Query("error_description")),
))
return
}|
基本可以合并了,大部分bug都修好了,这个 greptile-apps 有点意思,能发现这么多bug |
|
@LazuliKao |
这个PR里面其实有文档,当时合并进去了 不过是ai写的,有空我再更新一下,最近有点忙 |
Work in progress, feel free to point out any suggestion.
单点登录初步支持,请大家建言献策。目前在PocketID测试通过。
Related issue: #844
功能检查单:
仅允许OIDC登录,并提供配置开关Config Example:
附带实现了一些必要功能
密码修改
OIDC 认证流程
本文档说明当前这个 PR 中 OIDC 实现的前后端协作方式,主要用于帮助 reviewer 对照
internal/server/biz/oidc.go、internal/server/api/oidc.go以及前端frontend/src/features/auth相关代码理解整体设计。概览
AxonHub 当前采用的是“后端主导”的 OIDC 登录流程:
这意味着前端不会拿到原始的 IdP
id_token,也不会在浏览器里自己做 OIDC token 验证。端到端流程
flowchart TD U[用户在 /sign-in 点击某个 OIDC Provider] --> F1[前端调用<br/>GET /oauth/oidc/authorize/:provider] F1 --> B1[后端构造授权 URL<br/>并缓存 state/PKCE verifier] B1 --> IDP[浏览器跳转到 IdP] IDP --> B2[IdP 回调至回调接口<br/>GET /oauth/oidc/callback 或 /oauth/oidc/callback/:provider] B2 --> B3[后端校验 state / PKCE<br/>并交换授权码] B3 --> B4[后端校验 id_token<br/>并解析 claims] B4 --> B5{解析 AxonHub 用户} B5 -->|已有 issuer+subject 绑定| B6[复用已绑定用户] B5 -->|命中已验证邮箱| B7[把身份绑定到已有用户] B5 -->|无用户且 JIT 开启| B8[创建 AxonHub 用户<br/>并创建 OIDCIdentity] B6 --> B9[生成 5 分钟有效、一次性的<br/>exchange code] B7 --> B9 B8 --> B9 B9 --> F2[后端重定向到<br/>/oauth/oidc/idp-callback?code=...] F2 --> F3[前端回调页调用<br/>POST /oauth/oidc/exchange] F3 --> B10[后端校验 exchange code<br/>并签发 AxonHub JWT] B10 --> F4[前端写入 Zustand + localStorage<br/>并跳转进入应用]后端流程
公开路由
OIDC HTTP 路由注册在
internal/server/routes.go和internal/server/api/oidc.go中,统一挂在/oauth/oidc下:GET /oauth/oidc/providersGET /oauth/oidc/authorize/:providerGET /oauth/oidc/callbackGET /oauth/oidc/callback/:providerPOST /oauth/oidc/exchange此外,还有一个给已登录用户使用的受保护绑定入口:
GET /admin/oidc/link/:providerProvider 初始化
Provider 配置定义在
conf/conf.go的oidc.providers下,运行时逻辑在internal/server/biz/oidc.go。服务启动时,
NewOIDCService会基于配置里的issuer_url调用oidc.NewProvider(...)尝试初始化每个 provider,并为其构造 OAuth2 配置。后端给 IdP 提供的回调地址是:/oauth/oidc/callback/oauth/oidc/callback/:provider这个回调地址之后会再拼接上当前请求域名,或者
server.public_url,变成绝对 URL 发给 IdP。第一步:前端请求授权地址
前端通过
GET /oauth/oidc/authorize/:provider发起登录。OIDCService.GetAuthorizeURL(...)会:state。enable_pkce为真,则生成并缓存 PKCE verifier。如果某个 provider 刚失败过,服务端会对该 provider 应用 60 秒的重试退避,避免频繁重建。
第二步:IdP 回调到后端
用户在 IdP 完成登录后,会被重定向回:
GET /oauth/oidc/callbackGET /oauth/oidc/callback/:providerOIDCHandlers.Callback(...)会提取code与state,然后调用OIDCService.Callback(...)。在
OIDCService.Callback(...)内,后端会:oauth2.Config.Exchange(...)用授权码向 IdP 换 token。id_token。go-oidc校验id_token。email、email_verified、name、given_name、family_name、picture等 claims。第三步:解析或创建 AxonHub 用户
核心用户解析逻辑在
OIDCService.resolveUser(...)中,当前顺序如下:(issuer, subject)查找,命中后直接复用该用户。jit_enabled开启,则创建 AxonHub 用户,再创建对应的 OIDCIdentity。额外行为:
sync_user_info开启,登录或绑定时会用 OIDC claims 同步 AxonHub 用户的姓名与头像。require_email_verified开启,则新用户 JIT 创建要求email_verified=true。!OIDC_SSO_ONLY!,从而在biz/auth.go中阻止密码登录。第四步:生成短时 exchange code
当后端已经确认 AxonHub 用户之后,它不会直接在 IdP 回调阶段签发最终的 dashboard JWT。
相反,
OIDCService.Callback(...)会:userID缓存在oidc_exchange:<code>下。随后回调 handler 会把浏览器重定向到前端路由:
这样做的目的,是把 OIDC 协议细节留在后端,同时让前端仍然按照现有“拿 AxonHub 自己的 JWT 建立会话”的模型完成登录。
第五步:前端用 code 换 AxonHub JWT
前端回调页会调用
POST /oauth/oidc/exchange,把 exchange code 发回后端。OIDCService.ExchangeCode(...)会:随后
OIDCHandlers.Exchange(...)会调用AuthService.GenerateJWTToken(...)签发 AxonHub JWT,特征如下:HS256user_id、exp返回体格式如下:
{ "data": { "token": "<axonhub-jwt>", "user": { "...": "..." } } }前端流程
登录页
前端 OIDC 登录相关代码主要在:
frontend/src/features/auth/data/auth.tsfrontend/src/routes/oauth/oidc/idp-callback.tsxfrontend/src/lib/api-client.tsfrontend/src/stores/authStore.tsOIDC 登录按钮流程如下:
useOIDCProviders()从GET /oauth/oidc/providers获取可用 provider。useOIDCAuthorize()调用GET /oauth/oidc/authorize/:provider。前端回调路由
后端回调完成后,浏览器会进入公开前端路由:
frontend/src/routes/oauth/oidc/idp-callback.tsx这个页面会:
code、error、error_description。hasAttemptedRef避免 React strict mode 下重复触发。code存在时调用useOIDCExchange()。/sign-in。会话持久化
useOIDCExchange()在成功后会:axonhub_access_token。user.preferLanguage切换前端语言。/,非 owner 用户跳到/project/playground。对应的 auth store 定义在
frontend/src/stores/authStore.ts中,token 和 user 都会持久化到 localStorage。身份绑定与解绑
这个 PR 还支持已登录用户手动绑定额外的 OIDC 身份。
绑定流程
前端设置页
frontend/src/features/settings/security/oidc-management.tsx会调用:GET /admin/oidc/link/:provider后端复用普通授权流程,但会额外把当前用户 ID 缓存在
oidc_link_state:<state>下。当 IdP 回调回来时,OIDCService.Callback(...)会识别这是一个 link 操作,于是只创建OIDCIdentity记录,而不是发起新的登录会话。绑定成功后,后端会重定向到:
解绑流程
同一个设置页还支持通过 GraphQL 解绑 identity。它不属于 OIDC 登录协议本身,但会影响
GET /oauth/oidc/providers中返回的is_linked、linked_identity_id和linked_email字段。配置说明
OIDC 配置位于
oidc.providers。示例:
当前支持的字段
internal/server/biz/oidc.go中当前 provider 结构支持以下字段:name/oauth/oidc/authorize/:provider之类的路由参数。display_nameissuer_urlgo-oidc会基于它做发现。client_idclient_secretextra_scopesopenid profile email。jit_enabledauto_link_by_emailrequire_email_verifiedrole_mappingsrole_mapping_precedenceenable_pkceicon_urlbutton_colorsync_user_infoserver.public_urlOIDC 回调地址还依赖服务器基础 URL。
internal/server/api/oidc.go中的优先级是:server.public_urlscheme://host因此,如果 AxonHub 部署在反向代理后面,或者对外暴露的是固定公网域名,建议显式设置
server.public_url,确保发给 IdP 的 redirect URI 稳定且与 IdP 客户端配置一致。示例:
这个 PR 的 review 重点
从 reviewer 角度,这个 PR 最关键的设计点是把:
这两件事通过一个短时有效、一次性的 exchange code 串起来。这样既避免把原始 IdP token 暴露给浏览器,又能保持现有前端基于 AxonHub JWT 的登录/会话模型不变。
OIDC Authentication Flow
This document explains how the current OIDC implementation in this PR works across the backend and frontend. It is intended as a reviewer-oriented companion to the code in
internal/server/biz/oidc.go,internal/server/api/oidc.go, and the frontend OIDC login flow underfrontend/src/features/auth.Overview
AxonHub uses a backend-driven OIDC login flow:
This means the frontend never receives the raw IdP
id_token, and it does not perform OIDC token verification itself.End-to-end flow
flowchart TD U[User clicks an OIDC provider<br/>on /sign-in] --> F1[Frontend calls<br/>GET /oauth/oidc/authorize/:provider] F1 --> B1[Backend builds authorize URL<br/>and caches state/PKCE verifier] B1 --> IDP[Browser redirects to IdP] IDP --> B2[IdP redirects to callback endpoint<br/>GET /oauth/oidc/callback or /oauth/oidc/callback/:provider] B2 --> B3[Backend validates state / PKCE<br/>and exchanges auth code] B3 --> B4[Backend verifies id_token<br/>and reads claims] B4 --> B5{Resolve AxonHub user} B5 -->|Existing issuer+subject| B6[Reuse linked user] B5 -->|Verified email match| B7[Link identity to existing user] B5 -->|No user + JIT enabled| B8[Create AxonHub user<br/>and OIDC identity] B6 --> B9[Generate 5-minute single-use<br/>exchange code] B7 --> B9 B8 --> B9 B9 --> F2[Backend redirects to<br/>/oauth/oidc/idp-callback?code=...] F2 --> F3[Frontend callback route<br/>POSTs /oauth/oidc/exchange] F3 --> B10[Backend validates exchange code<br/>and issues AxonHub JWT] B10 --> F4[Frontend stores JWT in Zustand + localStorage<br/>and redirects into the app]Backend flow
Public routes
The OIDC HTTP endpoints are registered under
/oauth/oidcininternal/server/routes.goandinternal/server/api/oidc.go:GET /oauth/oidc/providersGET /oauth/oidc/authorize/:providerGET /oauth/oidc/callbackGET /oauth/oidc/callback/:providerPOST /oauth/oidc/exchangeThere is also a protected linking endpoint for already signed-in users:
GET /admin/oidc/link/:providerProvider initialization
Provider configuration is defined in
conf/conf.gounderoidc.providers, and the runtime implementation lives ininternal/server/biz/oidc.go.At startup,
NewOIDCServicetries to initialize each provider withoidc.NewProvider(...)using the configuredissuer_url. For each provider, it builds an OAuth2 client configuration with a backend callback path:/oauth/oidc/callbackwhen only one provider is configured/oauth/oidc/callback/:providerwhen multiple providers are configuredThe redirect URI sent to the IdP becomes absolute later by prepending the current request origin or
server.public_url.Step 1: frontend asks for the authorization URL
The frontend starts login by calling
GET /oauth/oidc/authorize/:provider.OIDCService.GetAuthorizeURL(...)then:statevalue.enable_pkceis enabled.If a provider failed recently, the service applies a 60-second retry backoff before re-initializing it again.
Step 2: IdP callback reaches the backend
After the user finishes authentication at the IdP, the provider redirects back to:
GET /oauth/oidc/callbackGET /oauth/oidc/callback/:providerOIDCHandlers.Callback(...)extracts the incomingcodeandstate, then callsOIDCService.Callback(...).Inside
OIDCService.Callback(...), the backend:oauth2.Config.Exchange(...).id_tokenfrom the OAuth2 token response.id_tokenusinggo-oidc.email,email_verified,name,given_name,family_name, andpicture.Step 3: resolve or create the AxonHub user
The core user resolution logic is in
OIDCService.resolveUser(...).The current implementation follows this order:
(issuer, subject)and reuse that user.jit_enabledis true, create a new AxonHub user and then create the OIDC identity record.Additional behavior:
sync_user_infois enabled, the backend updates the AxonHub user's profile fields from OIDC claims on login/link.require_email_verifiedis enabled, new-user provisioning requiresemail_verified=true.!OIDC_SSO_ONLY!, which blocks password login later inbiz/auth.go.Step 4: generate a short-lived exchange code
After the backend has resolved the AxonHub user, it does not issue the final dashboard JWT directly from the IdP callback.
Instead,
OIDCService.Callback(...):userIDin cache underoidc_exchange:<code>.The callback handler then redirects the browser to the frontend route:
This extra hop keeps the OIDC protocol details on the backend while still letting the frontend finish its own session setup in a controlled way.
Step 5: frontend exchanges the code for an AxonHub JWT
The frontend callback page calls
POST /oauth/oidc/exchangewith the exchange code.OIDCService.ExchangeCode(...)then:OIDCHandlers.Exchange(...)then callsAuthService.GenerateJWTToken(...), which returns an AxonHub JWT signed with the system secret key:HS256user_id,expThe response payload is:
{ "data": { "token": "<axonhub-jwt>", "user": { "...": "..." } } }Frontend flow
Sign-in page
The frontend login flow is implemented in:
frontend/src/features/auth/data/auth.tsfrontend/src/routes/oauth/oidc/idp-callback.tsxfrontend/src/lib/api-client.tsfrontend/src/stores/authStore.tsThe OIDC login button flow is:
useOIDCProviders()loads available providers fromGET /oauth/oidc/providers.useOIDCAuthorize()callsGET /oauth/oidc/authorize/:provider.Frontend callback route
After the backend callback completes, the browser lands on the public frontend route:
frontend/src/routes/oauth/oidc/idp-callback.tsxThat route:
code,error, anderror_descriptionfrom the URL.hasAttemptedRef.useOIDCExchange()whencodeis present./sign-inif the callback is invalid.Session persistence
useOIDCExchange()handles the successful exchange response by:axonhub_access_token.user.preferLanguage./and non-owners to/project/playground.The auth store is defined in
frontend/src/stores/authStore.tsand persists both the token and the user object in local storage.Linking and unlinking identities
The current PR also supports manual identity linking for existing signed-in users.
Link flow
The frontend settings UI in
frontend/src/features/settings/security/oidc-management.tsxcalls:GET /admin/oidc/link/:providerThe backend reuses the normal authorization flow, but also caches the current user ID under
oidc_link_state:<state>. When the IdP callback returns,OIDCService.Callback(...)detects that cached link state and creates anOIDCIdentityrecord instead of logging in a new session.When linking succeeds, the backend redirects to:
Unlink flow
The same settings UI can unlink an identity through GraphQL. That behavior is separate from the OIDC login protocol itself, but it affects what
GET /oauth/oidc/providersreturns in theis_linked,linked_identity_id, andlinked_emailfields.Configuration reference
OIDC configuration lives under
oidc.providers.Example:
Supported fields
The provider struct currently supports these fields in
internal/server/biz/oidc.go:name/oauth/oidc/authorize/:provider.display_nameissuer_urlgo-oidc.client_idclient_secretextra_scopesopenid profile email.jit_enabledauto_link_by_emailrequire_email_verifiedrole_mappingsrole_mapping_precedenceenable_pkceicon_urlbutton_colorsync_user_infoserver.public_urlOIDC callback URL generation also depends on the server base URL.
internal/server/api/oidc.gouses this priority:server.public_urlif configuredscheme://host)When AxonHub is behind a reverse proxy or deployed on a public domain,
server.public_urlshould be set so the generated redirect URI is stable and matches the IdP client configuration.Example:
What reviewers should pay attention to in this PR
From a review perspective, the most important design choice is the split between:
That split is implemented through the short-lived exchange code. It avoids exposing raw IdP tokens to the browser, while still preserving the existing frontend login/session model based on AxonHub-issued JWTs.