
# Go net/http sessions

Package `github.com/keenauth/keenauth-go/bff` adds sign-in to a Go `net/http` application. Your server is the OAuth client and keeps every token on the server. The browser gets only an encrypted, HttpOnly session cookie and never sees an access, refresh or ID token. This is the backend-for-frontend (BFF) pattern. The package uses only the standard library. It is not published yet.

To protect an API that receives bearer tokens instead, see [Protecting your API](/docs/sdk/protect-api).

## Before you start

1. In the dashboard, create a web application in your environment.
2. Choose client authentication: a client secret (`bff.ClientSecretBasic`) or `private_key_jwt` (`bff.PrivateKeyJWT`). For `private_key_jwt`, register the application's public key and keep the private key on your server.
3. Add the redirect URI `https://app.acme.example/auth/callback` and the post-logout redirect URI `https://app.acme.example/`.
4. Generate a cookie secret of at least 32 characters, for example with `openssl rand -base64 32`. Keep it in your secret store, not in the repository.

## Set up

```go
package main

import (
	"fmt"
	"net/http"
	"os"

	"github.com/keenauth/keenauth-go/bff"
)

func main() {
	auth, err := bff.New(bff.Options{
		Issuer:     "https://acme.eu.keenauth.app", // your environment's issuer, exactly
		ClientID:   "app_01j9zzzzzzzzzzzzzzzzzzzz",
		ClientAuth: bff.ClientSecretBasic{Secret: os.Getenv("KEENAUTH_CLIENT_SECRET")},
		AppBaseURL: "https://app.acme.example",
		Secrets:    []string{os.Getenv("KEENAUTH_COOKIE_SECRET")},
	})
	if err != nil {
		panic(err) // configuration errors stop the app at start-up
	}

	mux := http.NewServeMux()
	mux.Handle("GET /orders", auth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s := bff.FromContext(r.Context()).Session
		fmt.Fprintf(w, "Signed in as %s", s.User.Email)
	})))

	http.ListenAndServe(":8080", auth.Middleware(mux))
}
```

To use `private_key_jwt` instead of a client secret, pass the private key. The package signs a new client assertion for every token and PAR request (`iss` and `sub` are your client ID, `aud` is the issuer, lifetime 2 minutes, a new `jti` each time):

```go
pemBytes, err := os.ReadFile("/run/secrets/keenauth-app-key.pem")
if err != nil {
	panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes) // import "github.com/keenauth/keenauth-go/assertion"
if err != nil {
	panic(err)
}
auth, err := bff.New(bff.Options{
	Issuer:     "https://acme.eu.keenauth.app",
	ClientID:   "app_01j9zzzzzzzzzzzzzzzzzzzz",
	ClientAuth: bff.PrivateKeyJWT{Key: key, KeyID: "app-key-2026-11"},
	AppBaseURL: "https://app.acme.example",
	Secrets:    []string{os.Getenv("KEENAUTH_COOKIE_SECRET")},
})
```

ES256 (P-256), EdDSA (Ed25519), RS256 and PS256 (RSA, 2048 bits or more) are supported; the algorithm follows the key, and `Algorithm: "PS256"` selects PSS for an RSA key. `Key` can be any `crypto.Signer`, so a key held in a KMS or HSM works too. If you sign elsewhere, set `bff.PrivateKeyJWT{Assertion: f}` instead, where `f` returns a new signed assertion on every call. Set one of `Key` and `Assertion`, not both.

`AppBaseURL` is the public address of your app. The package builds the redirect URI and every redirect from it, never from the request's `Host` or `X-Forwarded-*` headers. Plain `http` is accepted only for `localhost`.

## Routes

`Middleware` serves three routes and reads the session on every other request. You can also mount the routes alone with `mux.Handle("/auth/", auth)`.

| Route | What it does |
|---|---|
| `GET /auth/login` | Starts sign-in. Optional query parameters: `returnTo` (where the user lands) and `organization` (sign in to an organization). |
| `GET /auth/callback` | Finishes sign-in and sets the session cookie. |
| `GET` or `POST /auth/logout` | Signs out at KeenAuth and returns to your app. |

Sign-in uses the authorization code flow with PKCE (S256). The request is pushed to the server (PAR) when your environment offers it. On the callback the package checks `state`, `iss` and the ID token, including its `nonce`. Each successful sign-in creates a new session; the old one is discarded.

`returnTo` must be a path of your own app, such as `/orders?tab=open`. Any other value, such as `//evil.example` or a full URL, is ignored and the user lands on `/`.

## Protect handlers

`auth.RequireAuth(handler)` lets a request through only with a session:

- a page request (`GET` or `HEAD`) without a session is redirected to `/auth/login` with `returnTo`;
- a request to `/api/…`, or with another method, gets `401` with a problem document. Pass `bff.GuardOptions{API: true}` to answer `401` on any path.

For `POST`, `PUT`, `PATCH`, `DELETE` and other unsafe methods, the browser must say the request comes from your app: `Sec-Fetch-Site: same-origin`, or an `Origin` header equal to your app's origin. Other requests get `403`. This protects against cross-site request forgery in addition to `SameSite=Lax`.

## Use the session and call your API

`bff.FromContext(r.Context())` returns:

- `Session`: `nil`, or the signed-in user. `Session.User` has typed ID token claims such as `Subject`, `Email`, `Name`, `OrgID` and `OrgRoles`; all claims are in `Session.User.Claims`. The session never contains a token.
- `AccessToken`: a valid access token for server-side calls, or `""`. Do not send it to the browser.

When the access token expires within 60 seconds, the package refreshes it before your handler runs. Each refresh rotates the refresh token and sets the new session cookie on the response. If the server refuses the refresh token, the session ends. A temporary server error keeps the session unchanged. Concurrent requests in one process share one refresh.

## Switch organization

`auth.SwitchOrganizationPath("org_01j9…", "/dashboard")` returns a link to `/auth/login` that signs the user in again in that organization. KeenAuth checks the membership. After sign-in, `Session.User.OrgID` holds the organization.

## Sign out

Send a `POST` to `/auth/logout` from a form. The package deletes the session cookie (and the stored session, if you use a store), revokes the refresh token and sends the browser to your environment's logout endpoint, which returns it to your app. A request that the browser marks as coming from another site is refused. Browsers that do not send `Sec-Fetch-Site` can sign out only with a `POST` from your own pages.

## Session storage

By default the whole session is in the cookie, encrypted and authenticated with AES-256-GCM using a key derived from the secret. A changed or copied cookie is rejected. The cookie is named `__Host-keenauth_session.0` and set with `Secure`, `HttpOnly`, `SameSite=Lax` and `Path=/`. On `http://localhost` the `__Host-` prefix and `Secure` are left out. Large sessions are split into up to three cookies.

To keep tokens on the server, set `Options.Store` to a `bff.SessionStore` (`Get`, `Set`, `Delete`), for example backed by Redis. The cookie then holds only an encrypted random session ID. Every sign-in creates a new ID and deletes the previous record. The store holds tokens, so restrict access to it. `bff.MemoryStore` is for development and tests only.

A session lasts at most 7 days (`SessionMaxAge`); refreshing does not extend it. To change the secret, list the new one first: `Secrets: []string{newSecret, oldSecret}`. Remove the old one after `SessionMaxAge`.

## Current limits

- DPoP-bound tokens and back-channel logout are not handled yet.
- Refreshes are shared only within one server process. With several instances, the application setting `refresh_reuse_interval_s` (default 5 seconds) lets a parallel refresh succeed; if you set it to `0`, the second refresh ends the session.
