Login Quickstart
Use a login client to act on behalf of a signed-in RemiliaNET user via Authorization Code with PKCE. A login client is public (no client secret).
For a React SPA, use oidc-spa. It handles the PKCE callback, token renewal, and logout.
To act only as your application, with no user involved, use an API client (Client Credentials) — see the API Quickstart.
Prerequisites
- A registered login client with a
client_id. - One or more redirect URIs registered for that client. The
redirect_uriyou send must match a registered value verbatim, or the authorization endpoint rejects it. - No client secret. PKCE with
S256is required. Never embed a secret in a login client. - The scopes your integration needs, from the scope reference.
React quickstart with oidc-spa
1. Register your app URLs
Register the app's base URL, including the trailing slash, as both a redirect URI and a post-logout redirect URI:
https://yourapp.example/
http://localhost:5173/Add the corresponding origins to the client's allowed web origins. oidc-spa uses the base URL as its callback; do not add a separate /callback route.
2. Install oidc-spa
npm install oidc-spa@^10For Vite, add the oidc-spa plugin:
// vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { oidcSpa } from "oidc-spa/vite-plugin";
export default defineConfig({
plugins: [react(), oidcSpa()],
});3. Configure the login client
# .env.local
VITE_REMILIA_CLIENT_ID=YOUR_LOGIN_CLIENT_ID// src/oidc.ts
import { oidcSpa } from "oidc-spa/react-spa";
export const {
bootstrapOidc,
useOidc,
getOidc,
OidcInitializationGate,
} = oidcSpa.createUtils();
void bootstrapOidc({
implementation: "real",
issuerUri: "https://www.remilia.net/oidc/realms/remilia",
clientId: import.meta.env.VITE_REMILIA_CLIENT_ID,
scopes: ["remilia:profile.private"],
});oidc-spa adds openid automatically. Add the remilia: scopes required by your app to scopes.
4. Gate the app and add login controls
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { OidcInitializationGate } from "./oidc";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<OidcInitializationGate fallback={<p>Loading session…</p>}>
<App />
</OidcInitializationGate>
</StrictMode>,
);// src/App.tsx
import { useOidc } from "./oidc";
export function App() {
const oidc = useOidc();
if (!oidc.isUserLoggedIn) {
return (
<button type="button" onClick={() => void oidc.login()}>
Sign in with RemiliaNET
</button>
);
}
return (
<button
type="button"
onClick={() => void oidc.logout({ redirectTo: "home" })}
>
Sign out
</button>
);
}5. Call the API
import { getOidc } from "./oidc";
const oidc = await getOidc({ assert: "user logged in" });
const accessToken = await oidc.getAccessToken();
const response = await fetch(
"https://www.remilia.net/api/v1/me/profile",
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);getAccessToken() renews the token when needed. See Endpoints for requests and Scopes for permissions.
Manual Authorization Code + PKCE
Use the manual flow when oidc-spa does not fit your application.
OIDC discovery
Issuer:
https://www.remilia.net/oidc/realms/remiliaRead endpoints from the discovery document at runtime rather than hardcoding them:
https://www.remilia.net/oidc/realms/remilia/.well-known/openid-configuration| Key in discovery document | Value |
|---|---|
authorization_endpoint | https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/auth |
token_endpoint | https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/token |
end_session_endpoint | https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/logout |
Step 1 — Generate state, PKCE verifier, and S256 challenge
Persist verifier and state before redirecting; you need them on the callback.
// base64url-encode raw bytes (no padding), per RFC 7636.
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("pkce_verifier", verifier);Step 2 — Redirect to the authorization endpoint
Send the user to authorization_endpoint with these parameters:
| Parameter | Value |
|---|---|
client_id | Your registered login client id |
response_type | code |
redirect_uri | A URI registered for your client, sent verbatim |
scope | openid plus any remilia: scopes, space-separated |
state | The random value from Step 1 |
code_challenge | The S256 challenge from Step 1 |
code_challenge_method | S256 |
const params = new URLSearchParams({
client_id: "YOUR_CLIENT_ID",
response_type: "code",
redirect_uri: "https://yourapp.example/callback",
scope: "openid remilia:profile.private",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.assign(
"https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/auth" +
`?${params}`,
);Request the narrowest scope set that does the job; see Scopes.
Step 3 — Validate the callback
On success RemiliaNET returns ?code=…&state=…; on failure, ?error=…&error_description=…. Confirm the returned state equals the stored value before proceeding; reject a mismatch.
const url = new URL(window.location.href);
const error = url.searchParams.get("error");
if (error) {
throw new Error(`${error}: ${url.searchParams.get("error_description")}`);
}
const returnedState = url.searchParams.get("state");
if (!returnedState || returnedState !== sessionStorage.getItem("oauth_state")) {
throw new Error("state mismatch — possible CSRF, aborting");
}
const code = url.searchParams.get("code");Step 4 — Exchange the code for tokens
POST to token_endpoint as application/x-www-form-urlencoded. A login client sends no client_secret; the code_verifier proves you started the flow.
const res = await fetch(
"https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: "YOUR_CLIENT_ID",
code,
redirect_uri: "https://yourapp.example/callback", // must match Step 2
code_verifier: sessionStorage.getItem("pkce_verifier"),
}),
},
);
if (!res.ok) {
throw new Error(`token exchange failed: ${res.status}`);
}
const tokens = await res.json();
// tokens.access_token — the bearer token for /api/v1
// tokens.expires_in — access-token lifetime in seconds
// tokens.refresh_token — renew without another redirect (Step 6)
// tokens.scope — the scope set issued with this token
// tokens.id_token — identity of the signed-in user (OIDC)
sessionStorage.removeItem("pkce_verifier");
sessionStorage.removeItem("oauth_state");With curl:
curl -X POST \
https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/token \
-d grant_type=authorization_code \
-d client_id=YOUR_CLIENT_ID \
-d code=AUTHORIZATION_CODE \
-d redirect_uri=https://yourapp.example/callback \
-d code_verifier=YOUR_PKCE_VERIFIERConfirm every scope you depend on appears in tokens.scope. Calls needing a missing scope fail with insufficient_scope (Step 5).
Where to run the exchange
A public client carries no secret, so the exchange can run in the browser. If your client's allowed web origins are not configured for cross-origin token requests, run the exchange from your backend, forwarding the code and code_verifier. Never introduce a client secret.
Step 5 — Call the API with the bearer token
Send the access token on any endpoint that requires a scope:
curl https://www.remilia.net/api/v1/me/profile \
-H "Authorization: Bearer $ACCESS_TOKEN"Step 6 — Refresh the token
Access tokens are short-lived. Exchange the refresh_token for a new access token instead of repeating the authorization flow:
const res = await fetch(
"https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: "YOUR_CLIENT_ID",
refresh_token: storedRefreshToken,
}),
},
);
const tokens = await res.json(); // new access_token (+ possibly a new refresh_token)Store the refresh_token from the latest response; when rotation is active, the old one stops working. Refresh before expires_in elapses. If the refresh token has expired or been revoked, the endpoint returns invalid_grant — fall back to the full login flow (Step 2).
Logout
Send the user to end_session_endpoint:
| Parameter | Value |
|---|---|
client_id | Your login client id |
post_logout_redirect_uri | Where to return the user after logout |
id_token_hint | The id_token from Step 4 (recommended) |
const params = new URLSearchParams({
client_id: "YOUR_CLIENT_ID",
post_logout_redirect_uri: "https://yourapp.example/",
id_token_hint: storedIdToken,
});
window.location.assign(
"https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/logout" +
`?${params}`,
);Clearing your local tokens signs the user out of your app; calling end_session_endpoint also ends their RemiliaNET session.
Security rules
- Never put a client secret in a login client. PKCE (
S256) protects the authorization code. - Always send
code_challenge_method=S256; neverplain. - Always verify
stateon the callback before trusting the response (Step 3). - Generate
stateand the PKCEverifierwithcrypto.getRandomValues, neverMath.random. - In the browser, keep tokens in memory or
sessionStorage, notlocalStorage, and never place them in a URL you log or link to. - On a server, keep the refresh token in server-side storage and never expose it to the browser.
- Request only the scopes your integration needs. See Scopes.
- Treat the access token as opaque.
Troubleshooting
| Where | Symptom | Cause and fix |
|---|---|---|
| Authorization redirect | error=invalid_redirect_uri (or an authorization error page) | redirect_uri does not match one registered for your client. Send a registered value verbatim. |
| Callback | state mismatch in your own check | The response is not tied to your request, or the browser lost the stored state. Restart the flow. |
| Token exchange | HTTP 400 invalid_grant | The code was already used or expired, the redirect_uri differs from Step 2, or the code_verifier is missing/wrong. Start a fresh authorization request. |
| Token refresh | HTTP 400 invalid_grant | The refresh token expired or was revoked. Fall back to the full login flow (Step 2). |
| API call | 401 invalid_token | The bearer token is malformed or expired. Refresh it (Step 6) and retry. |
| API call | 401 unauthorized | No valid token on a route that requires one. Send Authorization: Bearer <access_token>. |
| API call | 403 insufficient_scope | The token was not issued the required scope. Inspect tokens.scope and re-prompt when the user reaches for the feature. See Scopes. |
| API call | 403 forbidden | You used a token with the required scope but no user attached — an app-only API client token. Endpoints acting for a specific user require a user-delegated token from this login flow. |