# Hep.gg Login - OAuth2 / OIDC Identity Provider Hep.gg is an OpenID Connect (OIDC 1.0) identity provider. Use it to add "Sign in with Hep.gg" to your app. Apps and groups are self-serve: their owner creates and manages them in the Hep.gg dashboard (Login -> My Apps and Login -> My Groups). Any signed-in user with a verified email can sign in, unless the app restricts access to one or more of the owner's groups. Issuer: https://hep.gg Discovery: https://hep.gg/.well-known/openid-configuration JWKS: https://hep.gg/.well-known/jwks.json The discovery document is canonical; any well-behaved OIDC client library (node-openid-client, oidc-client-ts, Authlib, MSAL-style generic OIDC, Cloudflare Zero Trust "Generic OIDC", etc.) just needs the issuer URL. ## Endpoints (also in discovery) authorization_endpoint https://hep.gg/api/v1/login/oauth/authorize token_endpoint https://hep.gg/api/v1/login/oauth/token userinfo_endpoint https://hep.gg/api/v1/login/oauth/userinfo revocation_endpoint https://hep.gg/api/v1/login/oauth/revoke end_session_endpoint https://hep.gg/api/v1/login/oauth/end-session jwks_uri https://hep.gg/.well-known/jwks.json ## Capabilities response_types_supported ["code"] (authorization code only) grant_types_supported ["authorization_code", "refresh_token"] id_token_signing_alg_values ["RS256"] token_endpoint_auth_methods ["client_secret_basic", "client_secret_post"] code_challenge_methods_supported ["S256"] (PKCE optional per-client) scopes_supported ["openid","profile","email","groups","offline_access"] subject_types_supported ["public"] response_modes_supported ["query"] Notes: - PKCE is OPTIONAL by default per app. The app owner toggles a "Require PKCE" flag in the app's settings. Most server-side connectors (Cloudflare Zero Trust, Authentik-style consumers, traditional confidential clients) leave PKCE OFF. Browser SPAs and mobile apps typically have PKCE ON. - Verified email is REQUIRED. Users without users.email_verified_at are redirected to their profile to add one; the OIDC flow does not return a code until they do. - Group gating is REQUIRED when the app has allowed_groups set. Users not in any allowed group are sent to /signin/denied. Empty allowed_groups means any signed-in verified-email user passes. ## Claims (in id_token + userinfo) Always present: sub Stable user identifier (users.ID in hep.gg). Use this as the foreign key in your own DB. iss "https://hep.gg" aud Your client_id iat, exp, auth_time nonce Echoed back if you sent one on /authorize. Conditional on scope: scope=profile -> name, preferred_username, picture scope=email -> email, email_verified (email_verified is always true if email is present) scope=groups -> groups (array of group slugs, e.g. ["cf-zero-trust"]) Access tokens are also RS256 JWTs and carry an additional "scope" claim (space-delimited) and "token_use":"access" so userinfo can validate them. ## Flow (authorization code + optional PKCE) 1. Redirect the user to: GET https://hep.gg/api/v1/login/oauth/authorize ?response_type=code &client_id= &redirect_uri= &scope=openid+profile+email+groups &state= &nonce= &code_challenge= // only if PKCE required &code_challenge_method=S256 // only if PKCE required 2. Server flow: - No Hep.gg session -> 302 /signin?return= - No verified email -> 302 /dashboard/profile?needEmailForLogin=1 - App has allowed_groups but user is not in any -> 302 /signin/denied?app= - Otherwise -> 302 /signin/consent (or silent re-consent if the user already has an unrevoked refresh token for this app) 3. After consent, browser is redirected to: ?code=&state= The code is one-time-use, expires 60 seconds after issuance. Replaying it revokes the entire refresh-token chain for that user/app pair. 4. Exchange the code for tokens: POST https://hep.gg/api/v1/login/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code code= redirect_uri= client_id= (if not using Basic auth) client_secret= (if not using Basic auth) code_verifier= (only if PKCE was used) Client authentication: either include client_id + client_secret in the form body (client_secret_post), or send them in an HTTP Basic header (client_secret_basic): "Authorization: Basic base64(client_id:secret)". Successful response (200 OK): { "access_token": "", "refresh_token": "", "id_token": "", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile email groups" } Refresh tokens are only included when scope contains offline_access OR the call is a refresh grant. 5. Verify the id_token: - Fetch JWKS from https://hep.gg/.well-known/jwks.json (cache for the returned max-age; 24h is safe). - alg must be RS256, kid must match a JWK in the set. - Issuer must be exactly "https://hep.gg". - aud must equal your client_id. - exp must be in the future, iat in the past (allow ~60s skew). - nonce must match what you sent on /authorize. 6. Optional: call userinfo with the access token: GET https://hep.gg/api/v1/login/oauth/userinfo Authorization: Bearer Returns the same claims as id_token, scoped to what was requested. ## Refresh tokens POST https://hep.gg/api/v1/login/oauth/token grant_type=refresh_token refresh_token= client_id= client_secret= Tokens are rotated on every use: the response includes a new refresh_token and the one you sent is revoked. If you ever present a revoked token, the ENTIRE refresh-token chain for that user/app is revoked as a replay defense. Always update your stored refresh token immediately after a successful exchange. ## Revocation POST https://hep.gg/api/v1/login/oauth/revoke token= token_type_hint=refresh_token client_id= client_secret= Always returns 200 with an empty body. Access tokens are not revocable individually - they expire on their own (default 1 hour, configurable per app). ## Logout (RP-initiated) GET https://hep.gg/api/v1/login/oauth/end-session ?post_logout_redirect_uri= &state= Destroys the user's Hep.gg session and redirects them to post_logout_redirect_uri (or "/") with ?state=... appended. This does not reach into your app's session - kill your local session yourself. ## Errors Server-to-server errors return standard OAuth2 envelopes: { "error": "invalid_grant", "error_description": "Authorization code already used" } HTTP 400 / 401 / 403 depending on cause. Browser-side errors on /authorize redirect back to your redirect_uri with: ?error=&error_description=&state= Common error codes: invalid_request Missing or malformed parameter (e.g. PKCE required but no code_challenge sent). invalid_client Unknown client_id or wrong client_secret. invalid_grant Code expired, already used, or doesn't match the presented redirect_uri/code_verifier. invalid_scope Requested scope outside the app's allowed_scopes. unsupported_response_type response_type other than "code". access_denied User clicked Cancel on the consent screen. ## Cloudflare Zero Trust setup Zero Trust > Settings > Authentication > Add new > OpenID Connect (generic) Name: Hep.gg App ID: Client secret: Auth URL: https://hep.gg/api/v1/login/oauth/authorize Token URL: https://hep.gg/api/v1/login/oauth/token Certificate URL: https://hep.gg/.well-known/jwks.json Scopes: openid profile email groups PKCE: OFF (also turn "Require PKCE" off in your Hep.gg app settings; CF Access does not send a code_challenge.) Group claim mapping in CF Access policies: Selector: oidc_claims.groups contains ## Common pitfalls - "code_challenge is required" on /authorize: The app has PKCE required but the client did not send code_challenge. Either disable PKCE for that app in its settings (Login -> My Apps) or have the client send a real code_challenge + code_challenge_method=S256. - Redirect URI mismatch: redirect_uri is matched exactly. https://example.com/cb and https://example.com/cb/ are different. Query strings and trailing slashes count. - sub changes from Authentik to Hep.gg: Apps migrating off Authentik should remap stored Authentik sub UUIDs to the Hep.gg users.ID values. The sub is stable per Hep.gg user. - Group claim missing: Make sure you requested scope=groups. Some clients drop unknown scopes silently; Hep.gg returns invalid_scope if you ask for one the app isn't allowed to use.