Web app
A browser application signs users in with the OAuth 2.1 authorization code flow and PKCE. Gate is a standard OpenID Connect provider, so any conformant library works — you do not need a Gate SDK.
Register the client
Create a web client on the project's OIDC tab, or through the API. The client secret is
returned once.
const response = await fetch(
`https://evonia-gate.annatarhe.com/api/v1/projects/${projectId}/oidc-clients`,
{
method: "POST",
headers: { "content-type": "application/json", cookie: request.headers.get("cookie")! },
body: JSON.stringify({
name: "Rawback Web",
type: "web",
redirectUris: ["https://rawback.app/auth/callback"],
postLogoutRedirectUris: ["https://rawback.app/"],
scopes: ["openid", "profile", "email", "offline_access"],
}),
},
);
const { data } = await response.json();
// data.clientSecret is null on subsequent reads — store it now.curl -sS -X POST \
"https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/oidc-clients" \
-H "content-type: application/json" \
-b "better-auth.session_token=$SESSION" \
-d '{
"name": "Rawback Web",
"type": "web",
"redirectUris": ["https://rawback.app/auth/callback"],
"postLogoutRedirectUris": ["https://rawback.app/"],
"scopes": ["openid", "profile", "email", "offline_access"]
}'Rules Gate enforces on registration:
- Interactive clients (
webandnative) require at least one redirect URI. Up to 20 are allowed, and each must be an absolute URL. - If
environmentIdis given, it must belong to the project. offline_accessinscopesis what addsrefresh_tokento the client's grant types. Without it, you get authorization codes only and no refresh.- Registration requires a user session with
credential:manage. A service API key is rejected.
Discovery
Never hard-code endpoints. Read them from the discovery document and cache it.
curl -sS https://evonia-gate.annatarhe.com/.well-known/openid-configurationThe issuer is https://evonia-gate.annatarhe.com/api/auth, and the endpoints hang off it:
| Purpose | Endpoint |
|---|---|
| Authorization | /api/auth/oauth2/authorize |
| Token | /api/auth/oauth2/token |
| User info | /api/auth/oauth2/userinfo |
| Introspection | /api/auth/oauth2/introspect |
| Revocation | /api/auth/oauth2/revoke |
| End session | /api/auth/oauth2/end-session |
| JSON Web Keys | /.well-known/jwks.json |
The flow
-
Generate a PKCE verifier and state. Keep both server-side, in a short-lived HTTP-only cookie or session store.
-
Redirect to the authorization endpoint. Gate shows
/sign-inif the user has no session, then/consentunless the client is marked to skip consent. -
Receive the code at your redirect URI. Verify
statematches before doing anything else. -
Exchange the code at the token endpoint, authenticating with HTTP Basic and sending the verifier. Codes expire after 5 minutes and are single-use.
-
Verify the tokens before trusting them. See Verifying tokens.
Build the authorization URL
import { createHash, randomBytes } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const url = new URL("https://evonia-gate.annatarhe.com/api/auth/oauth2/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", "https://rawback.app/auth/callback");
url.searchParams.set("scope", "openid profile email offline_access");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
// Optional: ask for a token your own API will accept as its audience.
url.searchParams.set("resource", "https://api.rawback.app");verifierBytes := make([]byte, 32)
rand.Read(verifierBytes)
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
params.Set("redirect_uri", "https://rawback.app/auth/callback")
params.Set("scope", "openid profile email offline_access")
params.Set("state", state)
params.Set("code_challenge", challenge)
params.Set("code_challenge_method", "S256")
params.Set("resource", "https://api.rawback.app")
authorizeURL := "https://evonia-gate.annatarhe.com/api/auth/oauth2/authorize?" + params.Encode()Exchange the code
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
const response = await fetch("https://evonia-gate.annatarhe.com/api/auth/oauth2/token", {
method: "POST",
headers: {
authorization: `Basic ${basic}`,
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://rawback.app/auth/callback",
code_verifier: verifier,
}),
});
const tokens = await response.json();
// { access_token, token_type, expires_in: 600, refresh_token?, id_token, scope }curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://rawback.app/auth/callback \
-d code_verifier="$VERIFIER"form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", "https://rawback.app/auth/callback")
form.Set("code_verifier", verifier)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://evonia-gate.annatarhe.com/api/auth/oauth2/token", strings.NewReader(form.Encode()))
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)Lifetimes
| Token | Lifetime |
|---|---|
| Authorization code | 300 s, single use |
| Access token | 600 s |
| ID token | 600 s |
| Refresh token | 2,592,000 s (30 days) |
Refresh with grant_type=refresh_token and the same client authentication. Opaque access tokens are
prefixed gate_at_ and refresh tokens gate_rt_.
Signing out
Send the user to /api/auth/oauth2/end-session. Provide id_token_hint and, if you want them
returned to your app, a post_logout_redirect_uri that was registered on the client.
Clearing your own cookie does not end the Gate session — the next authorization request would sign them straight back in.
Cross-origin
/api/v1 allows credentialed requests only from the origins in src/server/origins.ts —
GATE_BASE_URL, https://shelltime.xyz, https://rawback.app, https://athena.annatarhe.com,
and https://athena.annatarhe.cn — accepting the headers content-type, authorization,
x-api-key, x-request-id, and idempotency-key. The same list is better-auth's trusted origins.