Skip to content

feat(auth): add SSO token exchange endpoint - #1134

Merged
qwqcode merged 3 commits into
ArtalkJS:masterfrom
sparkling:upstream-pr/sso-token-exchange
Jun 14, 2026
Merged

feat(auth): add SSO token exchange endpoint#1134
qwqcode merged 3 commits into
ArtalkJS:masterfrom
sparkling:upstream-pr/sso-token-exchange

Conversation

@sparkling

Copy link
Copy Markdown
Contributor

Closes #1133.

Adds POST /api/v2/sso/exchange — accepts an external OIDC IdP access
token (validated by calling the IdP's /userinfo) and returns the
same ResponseUserLogin shape /api/v2/user/access_token and the
social-login callback already return. The frontend can write the
returned token into localStorage["ArtalkUser"].token and the widget
treats the user as fully logged-in (no popup, no admin password
prompt for admin users), exactly the same way loginByToken does
after the popup callback today.

See #1133 for the use case discussion. tl;dr: lets a surrounding app
that already has an Auth0/Keycloak/Okta session embed Artalk without
showing Artalk's own login UI.

What's in the diff

File Change
server/handler/auth_sso_exchange.go NEW. The handler — ~110 lines. Calls {issuer}/userinfo, extracts email, FindCreateUser, LoginGetUserToken.
internal/config/config.go NEW AuthSSOConf{ Enabled, Issuer } struct, attached to AuthConf as SSO.
internal/config/cache.go Regenerated via go run internal/config/meta/gen/main.go -locale en -format go -o internal/config/cache.go to pick up the new env-path mappings (ATK_AUTH_SSO_ENABLED, ATK_AUTH_SSO_ISSUER).
conf/artalk.example.yml New commented auth.sso: block documenting the opt-in.
server/server.go One line — registers h.AuthSSOExchange(app, api) next to h.AuthSocialLogin.

API surface

POST /api/v2/sso/exchange
Content-Type: application/json

{ "token": "<external IdP access token>" }

200 OK
{
  "token": "<artalk JWT, HS256(app_key)>",
  "user": { "id": ..., "name": ..., "email": ..., ... }
}

400  no token / no email claim
401  IdP rejected the token (non-200 from /userinfo)
404  auth.sso.enabled = false
502  bad JSON back from /userinfo
503  /userinfo unreachable

Same response shape as /api/v2/auth/email/login so the frontend's
existing loginByApiRes helper consumes it without change.

Backward compatibility

Gated off by default — auth.sso.enabled is false in the example
config, and the handler returns 404 unless both auth.enabled and
auth.sso.enabled are true. Nothing else in the codebase reads the
new fields. Existing deploys are unaffected.

Why no JWKS dependency

OIDC's /userinfo is the spec-defined way to validate an access
token at a relying party — the IdP signs the response server-side
and bounces non-200 on invalid/revoked tokens. Adding a JWKS
verifier (e.g. MicahParks/keyfunc) would let us avoid the
round-trip but pulls in a dep and a key-rotation cache I didn't
want to maintain in the same PR. Happy to follow up with JWKS as
a perf optimisation if you'd take it.

Tested against

Auth0 tenant sparklesparkle.auth0.com, running this fork in
production on Railway since 2026-05-19. The endpoint is also live
right now at https://artalk-production-4ade.up.railway.app/api/v2/sso/exchange
— a quick curl -X POST -d '{"token":"x"}' returns
{"msg":"SSO token rejected by issuer"} as expected (Auth0 401 → 401
to the caller). Real tokens from the Auth0 SPA SDK round-trip
correctly and return Artalk JWTs that the widget accepts.

Out of scope (could be follow-ups)

  • JWKS-based local validation (faster, fewer external hops)
  • Audience / aud claim check (currently relies entirely on
    /userinfo rejection)
  • Mapping additional claims onto the user record (avatar from
    picture, etc.)

Happy to iterate on naming, location, or scope.

Adds POST /api/v2/sso/exchange — accepts an access token from an
external OIDC IdP (verified by calling the IdP's /userinfo) and issues
an Artalk session JWT for the user identified by the email claim.

Use case: a surrounding application that already runs OIDC (e.g. an
Auth0 SPA) can drive Artalk sign-in without showing Artalk's own popup
login UI. The widget reads the resulting JWT from
localStorage["ArtalkUser"].token and treats the user as fully
authenticated for is_login/is_admin checks.

Implementation:
- One new handler at server/handler/auth_sso_exchange.go (~110 lines)
- Reuses dao.FindCreateUser + common.LoginGetUserToken; same JWT
  format as /user/access_token, so no client-side changes needed
- Validation by calling {issuer}/userinfo with Authorization: Bearer
  — OIDC-standard, no JWKS dep added
- Gated by auth.enabled && auth.sso.enabled (404 otherwise)
- Returns the same ResponseUserLogin shape as /auth/email/login

Config additions (conf/artalk.example.yml):
  auth:
    sso:
      enabled: false
      issuer: ""   # e.g. "tenant.auth0.com"

Route registered alongside the existing social-login routes in
server/server.go.

Verified by go build ./... (clean).
Required so koanf actually maps ATK_AUTH_SSO_ENABLED/ISSUER env vars
to the new auth.sso config block introduced in feat: add SSO token
exchange endpoint. Without this regen the env vars are silently
ignored and the endpoint stays in its disabled state.
@qwqcode
qwqcode force-pushed the upstream-pr/sso-token-exchange branch from f2599a4 to e3b4757 Compare June 14, 2026 10:17
@qwqcode

qwqcode commented Jun 14, 2026

Copy link
Copy Markdown
Member

I've added some additional documentation, thanks for the PR! ❤️

@qwqcode
qwqcode merged commit cf06808 into ArtalkJS:master Jun 14, 2026
10 checks passed
thun888 pushed a commit to thun888/Artalk that referenced this pull request Jul 28, 2026
* feat(auth): add SSO token exchange endpoint

Adds POST /api/v2/sso/exchange — accepts an access token from an
external OIDC IdP (verified by calling the IdP's /userinfo) and issues
an Artalk session JWT for the user identified by the email claim.

Use case: a surrounding application that already runs OIDC (e.g. an
Auth0 SPA) can drive Artalk sign-in without showing Artalk's own popup
login UI. The widget reads the resulting JWT from
localStorage["ArtalkUser"].token and treats the user as fully
authenticated for is_login/is_admin checks.

Implementation:
- One new handler at server/handler/auth_sso_exchange.go (~110 lines)
- Reuses dao.FindCreateUser + common.LoginGetUserToken; same JWT
  format as /user/access_token, so no client-side changes needed
- Validation by calling {issuer}/userinfo with Authorization: Bearer
  — OIDC-standard, no JWKS dep added
- Gated by auth.enabled && auth.sso.enabled (404 otherwise)
- Returns the same ResponseUserLogin shape as /auth/email/login

Config additions (conf/artalk.example.yml):
  auth:
    sso:
      enabled: false
      issuer: ""   # e.g. "tenant.auth0.com"

Route registered alongside the existing social-login routes in
server/server.go.

Verified by go build ./... (clean).

* chore(config): regenerate env-path cache with auth.sso entries

Required so koanf actually maps ATK_AUTH_SSO_ENABLED/ISSUER env vars
to the new auth.sso config block introduced in feat: add SSO token
exchange endpoint. Without this regen the env vars are silently
ignored and the endpoint stays in its disabled state.

* docs: add sso token exchange section for auth docs

---------

Co-authored-by: qwqcode <qwqcode@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(auth): SSO token exchange endpoint for invisible OIDC login in embedded contexts

2 participants