# Server-rendered apps

> Sign users in from Next.js, Express, Hono or any back end with a confidential client and a server session.

Source: https://9in.io/docs/server-apps/

A server-rendered app (Next.js, Express, Hono, Rails, Django, Go…) can keep a secret, so it is a **confidential client**: it exchanges the code on the server with its client secret, keeps the tokens in the server-side session, and the browser only ever holds a session cookie.

## Set up the application

**Applications → Create application → Regular Web App**, then:

- **Allowed callback URLs:** `https://app.acme.com/callback` (and `http://localhost:3000/callback` for development).
- **Allowed logout URLs:** `https://app.acme.com/`.
- Copy the **Client ID**, and the **Client secret** from the **Credentials** tab into your server's environment as `NINE_CLIENT_SECRET`. Rotate it there when needed; the previous secret keeps working until the next rotation.

## Node.js with openid-client

[openid-client](https://github.com/panva/openid-client) is a certified OpenID Connect client for Node, Bun, Deno and Workers.

```ts
import * as client from "openid-client";
import express from "express";
import session from "express-session";

const config = await client.discovery(new URL(process.env.NINE_ISSUER!), process.env.NINE_CLIENT_ID!, process.env.NINE_CLIENT_SECRET!);
const redirect_uri = "https://app.acme.com/callback";

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: "lax" } }));

app.get("/login", async (req, res) => {
  const verifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  req.session.login = { verifier, state, returnTo: req.query.returnTo };
  res.redirect(
    client.buildAuthorizationUrl(config, {
      redirect_uri,
      scope: "openid profile email offline_access",
      code_challenge: await client.calculatePKCECodeChallenge(verifier),
      code_challenge_method: "S256",
      state,
      // prompt: "create" opens the sign-up form; ui_locales: "de" picks the language
    }).href,
  );
});

app.get("/callback", async (req, res) => {
  const { verifier, state, returnTo } = req.session.login ?? {};
  const tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, "https://app.acme.com"), { pkceCodeVerifier: verifier, expectedState: state });
  req.session.login = undefined;
  req.session.user = tokens.claims();          // sub, email, name…
  req.session.tokens = { id_token: tokens.id_token, refresh_token: tokens.refresh_token, access_token: tokens.access_token, expires_at: Date.now() + (tokens.expires_in ?? 0) * 1000 };
  res.redirect(typeof returnTo === "string" && returnTo.startsWith("/") ? returnTo : "/");
});

app.get("/logout", (req, res) => {
  const idToken = req.session.tokens?.id_token;
  req.session.destroy(() => {
    res.redirect(client.buildEndSessionUrl(config, { id_token_hint: idToken, post_logout_redirect_uri: "https://app.acme.com/" }).href);
  });
});
```

Refresh an expired access token with `client.refreshTokenGrant(config, refreshToken)`; store the new refresh token it returns (they rotate).

## Next.js with Auth.js

```ts
// auth.ts
import NextAuth from "next-auth";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "9in",
      name: "9in",
      type: "oidc",
      issuer: process.env.NINE_ISSUER,           // https://acme.9in.io
      clientId: process.env.NINE_CLIENT_ID,
      clientSecret: process.env.NINE_CLIENT_SECRET,
      authorization: { params: { scope: "openid profile email" } },
    },
  ],
});
```

Register `https://app.acme.com/api/auth/callback/9in` as the callback URL. Protect pages with `const session = await auth()` in server components, or `export { auth as middleware } from "./auth"`.

## The 9in Node SDK

`@9in-io/node` wraps the same flow with 9in's defaults (PKCE, state, issuer and ID token checks, refresh with rotation):

```ts
import { ServerAuth } from "@9in-io/node";

const auth = new ServerAuth({
  issuer: process.env.NINE_ISSUER!,
  clientId: process.env.NINE_CLIENT_ID!,
  clientSecret: process.env.NINE_CLIENT_SECRET,
  redirectUri: "https://app.acme.com/callback",
});

app.get("/login", async (req, res) => {
  const { url, pending } = await auth.createLogin({ returnTo: req.query.returnTo as string });   // screen: "signup" for the sign-up form
  req.session.nineLogin = pending;
  res.redirect(url);
});

app.get("/callback", async (req, res) => {
  const tokens = await auth.handleCallback(req.originalUrl, req.session.nineLogin);
  req.session.nineLogin = undefined;
  req.session.tokens = tokens;
  req.session.user = auth.claims(tokens);
  res.redirect(tokens.returnTo ?? "/");
});

// later: req.session.tokens = await auth.ensureFresh(req.session.tokens)
app.get("/logout", async (req, res) => {
  const tokens = req.session.tokens;
  if (tokens?.refreshToken) await auth.revoke(tokens.refreshToken);
  req.session.destroy(() => undefined);
  res.redirect(await auth.logoutUrl({ idToken: tokens?.idToken, returnTo: "https://app.acme.com/" }));
});
```

## Other languages

Any certified OpenID Connect relying-party library works with the issuer, client ID and secret: Spring Security (`spring.security.oauth2.client.provider.nine.issuer-uri`), ASP.NET Core (`AddOpenIdConnect` with `Authority`), Django (`mozilla-django-oidc`), Rails (`omniauth_openid_connect`), Go (`github.com/coreos/go-oidc`). Point them at the issuer; everything else comes from `<issuer>/.well-known/openid-configuration`.

## Good to know

- **Client authentication:** `client_secret_basic` (HTTP Basic) or `client_secret_post`.
- **PKCE is recommended for confidential clients too** (OAuth 2.1); 9in accepts S256.
- **Keep the ID token** to pass `id_token_hint` at sign-out (or pass `client_id`): 9in needs to know which application is signing out to accept its `post_logout_redirect_uri`.
