Login Quickstart
Add "Sign in with RemiliaNET" to your app. By the end of this guide, a user can sign in to your application, and your frontend can call the API on their behalf.
Login clients are public, and intended to be embedded on your application's frontend. They allow you to make requests to the API on behalf of a user, after that user has signed in to your application. To act only as your application, with no user involved, use an API client (Client Credentials) instead. See the API Quickstart.
Register your login client
- Sign in at
https://www.remilia.netand open the developer portal from your profile menu. - Create an application and pick the Login (public) client type.
- Register every redirect URI your app will use. The
redirect_uriyou send in a login flow must match a registered value verbatim, or the authorization endpoint rejects it. (For an SPA that is the app's base URL — see step 1 below.) - Select the scopes your integration needs from the scope reference.
- Submit. Your application page shows the assigned client ID — the only credential a login client has. Never embed a client secret in one.
React quickstart with oidc-spa
1. Register your app URLs
Register the app's base URL, including the trailing slash, as a redirect URI:
https://yourapp.example/
http://localhost:5173/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,
});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", {
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 — your client's remilia: scopes are attached automatically |
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",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.assign(
"https://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/auth" +
`?${params}`,
);The /callback path above is only an example — send any URI registered for your client.
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, and reject mismatches.
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.
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_VERIFIERtokens.scope lists the scopes your client was granted. Calls to an endpoint whose scope is outside that grant fail with insufficient_scope (Step 5).
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 \
-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 — must match a registered redirect URI verbatim |
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.
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. |
| Authorization redirect | The sign-in page reports an unknown or invalid client | The application is not approved yet, or client_id is mistyped. Check the application's status in the developer portal. |
| 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 endpoint requires a scope outside your client's grant. Inspect tokens.scope, then apply for the scope in the developer portal and have the user sign in again. See Scopes. |
| API call | 403 requires_user | 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. |