Skip to content

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_uri you send must match a registered value verbatim, or the authorization endpoint rejects it.
  • No client secret. PKCE with S256 is 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:

text
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

sh
npm install oidc-spa@^10

For Vite, add the oidc-spa plugin:

ts
// 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

dotenv
# .env.local
VITE_REMILIA_CLIENT_ID=YOUR_LOGIN_CLIENT_ID
ts
// 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

tsx
// 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>,
);
tsx
// 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

ts
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/remilia

Read 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 documentValue
authorization_endpointhttps://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/auth
token_endpointhttps://www.remilia.net/oidc/realms/remilia/protocol/openid-connect/token
end_session_endpointhttps://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.

js
// 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:

ParameterValue
client_idYour registered login client id
response_typecode
redirect_uriA URI registered for your client, sent verbatim
scopeopenid plus any remilia: scopes, space-separated
stateThe random value from Step 1
code_challengeThe S256 challenge from Step 1
code_challenge_methodS256
js
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.

js
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.

js
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:

sh
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_VERIFIER

Confirm 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:

sh
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:

js
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:

ParameterValue
client_idYour login client id
post_logout_redirect_uriWhere to return the user after logout
id_token_hintThe id_token from Step 4 (recommended)
js
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; never plain.
  • Always verify state on the callback before trusting the response (Step 3).
  • Generate state and the PKCE verifier with crypto.getRandomValues, never Math.random.
  • In the browser, keep tokens in memory or sessionStorage, not localStorage, 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

WhereSymptomCause and fix
Authorization redirecterror=invalid_redirect_uri (or an authorization error page)redirect_uri does not match one registered for your client. Send a registered value verbatim.
Callbackstate mismatch in your own checkThe response is not tied to your request, or the browser lost the stored state. Restart the flow.
Token exchangeHTTP 400 invalid_grantThe 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 refreshHTTP 400 invalid_grantThe refresh token expired or was revoked. Fall back to the full login flow (Step 2).
API call401 invalid_tokenThe bearer token is malformed or expired. Refresh it (Step 6) and retry.
API call401 unauthorizedNo valid token on a route that requires one. Send Authorization: Bearer <access_token>.
API call403 insufficient_scopeThe token was not issued the required scope. Inspect tokens.scope and re-prompt when the user reaches for the feature. See Scopes.
API call403 forbiddenYou 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.

Built by Remilia Corporation