Oauth
Eternaltwin sits on both sides of the OAuth 2.0 authorization framework, and the two roles are easy to confuse:
- Eternaltwin as the authorization server. Every game website is an OAuth 2 client of Eternaltwin: it sends its players to Eternaltwin to sign in, and receives an access token letting it read Eternaltwin data on their behalf. The first half of this page describes that side; the step-by-step guide written for an application author is Eternaltwin for OAuth.
- Eternaltwin as an OAuth client. Eternaltwin is itself a client of Discord and of GitLab, so a member can sign in with either account, or link one to their Eternaltwin profile. There the authorization server is Discord or GitLab, and the scopes, callbacks and secrets involved have nothing to do with the ones above. The second half of this page describes that side.
Nothing is shared between the two beyond the /oauth/ URL prefix and the shape of the protocol.
Eternaltwin as an authorization server
Client creation
Registering a client is not exposed publicly: it is done by whoever runs the server, in one of two ways.
- A
[seed.app.<key>]section ineternaltwin.toml. It is applied every time the backend starts, which is why registering a client means restarting the server. This is the documented route, and Eternaltwin for OAuth walks through it. POST /api/v1/oauth_clients, which takes the same fields as JSON. It requires the internal authentication key, so it is not reachable by an application.
A client is described by:
- General information: display name, homepage URI
- Technical: OAuth redirect (callback) URI, client secret
- Permissions:
allowed_scopes, the ceiling on what the client may request (see below)
The secret is chosen by the operator, not generated by the server, and the server never sends it back: the registration response describes the client (id, key, display name, app URI, callback URI, owner) and carries no secret at all. A client that lost its secret is given a new one by changing the configuration and restarting.
The server stores both a scrypt hash of the secret and the secret itself, each encrypted with the database secret: authentication reads the hash, and the clear copy exists so that an operator can hand a client its secret back. Anyone holding the database and the server secret can therefore recover it — treat a leak of either as a leak of every client secret.
Each client is identified by an Id (a UUID, regenerated whenever the client is registered again)
and a Key (<name>@clients, stable). Either works as the client_id; the Key is recommended.
Authorization request
To authenticate a user, the client must redirect the end user to Eternaltwin to request its authorization.
In development, send the browser to the website, not to the backend:
http://localhost:50321/oauth/authorize, or http://localhost:4200/oauth/authorize under
ng serve. Both proxy /oauth/ to the backend on port 50320. Pointing the browser straight at
http://localhost:50320/ does not work: /oauth/authorize answers with a redirect to /login,
/tos-accept or /consent, and those pages are served by the Angular application, not by the
backend.
The base URI is https://eternaltwin.org/oauth/authorize, with the following query parameters:
| Name | Description |
|---|---|
| client_id | Required. The client's Id or Key |
| response_type | Required. The string code. token parses but is not implemented and is answered with 501 |
| redirect_uri | Optional. If present, it must be exactly the callback URI registered for the client |
| scope | Space-separated permissions to request. See below |
| state | A string returned as-is |
No other parameter is read. In particular access_type=offline, which the integration guide tells
applications to send and which some OAuth libraries add on their own, is accepted and ignored: it is
a Google extension, and Eternaltwin issues no refresh tokens.
A request that fails validation is answered with an error page on Eternaltwin rather than a redirect
back to the client: a missing or unparsable client_id, an unknown client and a redirect_uri that
does not match the registered one are all treated as possibly malicious and are never redirected.
A missing response_type, an unusable one or a bad scope is answered with 422 as well. The one
case that does redirect to the client is the user refusing consent (see below).
Two detours are normal and end back on this endpoint: a signed-out user is sent to /login, and a
user who has not accepted the terms of service is sent to /tos-accept. Both come back with the
authorization request intact.
Parameters
State
The state parameter is a string returned as-is once the user has authenticated. It must include an unguessable part to prevent CSRF attacks.
For the system clients, we use a JWT based on this RFC draft for the state. In particular, it has a "Request Forgery Protection" (rfp) field.
Scope
The scopes are base, forum:read, forum:write and forum:moderate. They form a chain —
moderating implies writing, writing implies reading — so a request naming one is granted the ones
below it and there is no reason to list them. An omitted or empty scope asks for base alone,
which is identity and nothing more; base is part of every granted set, whether or not it was
named. A token naming anything else is refused outright rather than ignored.
A client may only request what its allowed_scopes permits; asking for more is refused. A client
registered without an allowed_scopes key keeps whatever allowance it already had, and one that was
never given any may only request base — the ceiling is never widened by an omission, so no
existing client can reach the forum by accident. See
The forum from an application, which describes what each forum scope buys.
Consent
The user is asked to approve the scopes a client requests. /oauth/authorize redirects them to the
consent page at /consent, which comes back here once they have answered.
The approval is remembered per user and per client, and a later request covered by it skips the screen. Approving is additive: a wider request stores the union of the old and the new sets, so answering a narrower request never quietly takes away a scope approved earlier.
Refusing sends the browser back to the client's registered callback URI with
error=access_denied and the original state. This is the one place Eternaltwin speaks the RFC
6749 error format: a redirect carries no response body, and error=access_denied is the only
spelling a generic OAuth library understands. A client that asked a question is owed an answer —
one left waiting on a redirect that never comes cannot tell a refusal from a network failure.
Redirect URI
Eternaltwin does not allow dynamic redirect URIs. Use the state parameter to encode state.
For the system clients, we use https://<game>/oauth/callback if the client supports only one authorization server (Eternaltwin), or https://<game>/oauth/callback/<as> where as is a string identifying the authorization server.
Access token request
Once the user is redirected back to the client callback, the client exchanges the authorization
code for an access token with POST https://eternaltwin.org/oauth/token, authenticating itself
with its client id and secret.
Authorization codes are valid for 10 minutes.
The response is a Bearer token with the scopes the user approved. There is no refresh token, and
expires_in is about a billion seconds: access tokens do not expire in practice yet.
Errors
Errors are not reported in the RFC 6749 format:
there is no error / error_description pair. Eternaltwin replies with an HTTP error status and
its own JSON error object:
{"code": "Q1012", "message": "the user has not accepted the terms of service", "extra": null}
A generic OAuth library only reports the HTTP status for these; read the code field to tell them
apart. The Node client exposes them as ErrorCode (@eternaltwin/client-node), and
RfcOauthClient#getAccessToken (@eternaltwin/oauth-client-http) parses the body into
GetAccessTokenError#eternaltwin.
| Code | Status | Meaning |
|---|---|---|
Q0002 | 401 | Missing or invalid client authentication |
Q1008 | 422 | Missing code parameter |
Q1010 | 422 | Malformed authorization code |
Q1011 | 422 | Expired (or not yet valid) authorization code |
Q1009 | 403 | The code was issued for a different client |
Q1012 | 403 | The user has not accepted the terms of service |
S0000 | 500 | Internal error |
Terms of service (Q1012)
A user who has not accepted the Eternaltwin terms of service can not take part in an OAuth grant.
/oauth/authorize redirects such a user to the acceptance page and comes back to the authorization
request once they accept, so a browser flow normally never reaches this error. It is still returned
by /oauth/token for codes obtained some other way (a code issued before the user's acceptance was
revoked, a non-browser flow, a replayed code).
Clients receiving Q1012 should:
- treat it as final and user-actionable, not transient: do not retry automatically. The code stays claimable for the rest of its 10 minutes, but retrying is pointless until the user acts.
- abort the sign-in without creating a local account or session: there is no access token, hence no identity.
- tell the user their Eternaltwin account must accept the terms of service, link them to
https://eternaltwin.org/tos-accept, and offer to restart the flow at
/oauth/authorize.
Access tokens issued before this check existed are unaffected: acceptance is only verified when a token is granted, never when it is used.
Eternaltwin as an OAuth client: Discord and GitLab
Eternaltwin is a registered OAuth 2 application on Discord and on the configured GitLab instance. Both support the same two actions:
- Sign in:
POST /actions/login/discord,POST /actions/login/gitlab. This only works for a profile that some Eternaltwin account has already claimed. An unclaimed profile is refused rather than turned into a new account: minting an account for every unlinked profile is how ghost accounts accumulate, including right after somebody unlinks. - Link:
POST /actions/link/discord,POST /actions/link/gitlab, for a signed-in user. A profile already linked to another user is refused.
Linking is offered in two places: the settings page, and the last step of registration
(/register/link), which is where a new account claims its Discord or GitLab profile so that the
sign-in buttons mean something afterwards. Skipping that step costs one click. The two buttons post
to the same /actions/link/* endpoints as the settings page; what the registration step adds is a
return_to, so the provider sends the user back to the step and the second provider can be linked
without leaving it.
Providers
| Discord | GitLab | |
|---|---|---|
| Authorization endpoint | https://discord.com/oauth2/authorize | {gitlab.base_uri}/oauth/authorize |
| Token endpoint | https://discord.com/api/oauth2/token | {gitlab.base_uri}/oauth/token |
| Callback | {frontend.uri}/oauth/callback/discord | {frontend.uri}/oauth/callback/gitlab |
| Scope requested | identify | read_user |
| What it reads | GET /api/v10/users/@me | GET {gitlab.base_uri}/api/v4/user |
Both scopes are the narrowest ones granting a user's id and display name, which is all Eternaltwin asks of either provider. GitLab's endpoints are derived from the configured instance rather than hardcoded, because GitLab is self-hostable; Discord's are constants.
Discord receives prompt=none on the authorization request, without which it asks the user to
re-approve the same scopes on every single sign-in. It only skips the screen for an authorization
that already exists, so a first-time user is still asked. GitLab has no such parameter and already
skips its own screen once the application is authorized. Neither provider receives access_type:
that is a Google extension both ignore.
Each provider gets its own callback path rather than sharing /oauth/callback, which keeps the
routing explicit. The shared /oauth/callback route is a leftover of the retired Twinoid flow and
serves nothing.
Configuration
Credentials live in the [discord] and [gitlab] sections of eternaltwin.toml. Both take
client_id and secret, which are server-side secrets and must never reach the frontend; gitlab
also takes base_uri, defaulting to https://gitlab.com/.
[discord] client_id = "…" secret = "…" [gitlab] client_id = "…" secret = "…" # base_uri = "https://gitlab.com"
The Discord credentials come from the Discord Developer Portal, the GitLab ones from the instance's
"Applications" settings. The GitLab application must be created with the read_user scope and the
callback URI above; the Discord application needs no privileged intent or guild permission, since
identify is all that is requested.
Both are only required when backend.oauth_client is Network, which is the default of the dev
and production profiles. The sdk and test profiles default to Mock instead: the flows then
run against an in-memory client that needs no credentials and can be driven through the API, which
is what the tests use.
Flow
The two flows are identical apart from the provider. Eternaltwin does not use the provider's access token for anything beyond reading the profile once, and does not store it.
- The action endpoint mints a state: a compact JWT holding the action (
Login, orLinkwith the user id), the authorization server it was issued for, and a request-forgery-protection nonce. It is valid for 15 minutes. - The nonce is also set as an
HttpOnly,SameSite=Laxcookie, so it survives the redirect back from the provider but is not readable by scripts. The browser is then redirected to the provider. - The provider redirects back to the callback path with
codeandstate. Eternaltwin checks the state's signature and validity, that it was issued for this provider, and that its nonce matches the cookie. The cookie is single-use and is cleared whatever the outcome. - The code is exchanged for a provider access token, the profile is read, and the flow either opens a session (sign in) or records the link.