
# Serveur mock local

`keenauth mock serve` lance sur votre machine un petit serveur OpenID Connect, SAML 2.0 et SCIM 2.0, ainsi qu'une version en mémoire de l'API de gestion `/v1`, pour les tâches CI sans accès réseau et pour le développement local. Il n'a besoin ni d'environnement KeenAuth, ni de clé API, ni d'Internet. Toutes les données sont conservées en mémoire et supprimées à la fin du processus.

Le serveur mock n'est pas KeenAuth. Il n'implémente que les flux ci-dessous, approuve automatiquement chaque requête et vérifie beaucoup moins que le service réel. **Votre environnement `development` hébergé est la seule référence pour le comportement réel** : exécutez vos tests de bout en bout contre lui avant toute mise en production.

## Démarrer le serveur

```sh
keenauth mock serve
```

Par défaut, le serveur écoute sur `127.0.0.1:4010`, une adresse de boucle locale qu'aucune autre machine ne peut atteindre. `--port` change le port (`0` en choisit un libre). `--host` change l'adresse ; toute adresse autre qu'une adresse de boucle locale expose le serveur mock à votre réseau, et la CLI affiche un avertissement. Arrêtez le serveur avec Ctrl+C.

La commande ne lit aucun identifiant et n'envoie rien à KeenAuth.

## Points de terminaison

| Chemin | Rôle |
|---|---|
| `/.well-known/openid-configuration` | Document de découverte OIDC (également servi sur `/.well-known/oauth-authorization-server`) |
| `/.well-known/jwks.json` | Jeu de clés publiques de test (identifiant de clé `keenauth-test-1`) |
| `/oauth2/authorize` | Point de terminaison d'autorisation : flux par code d'autorisation avec PKCE (`S256`) |
| `/oauth2/token` | Point de terminaison de jetons : grants `authorization_code` et `refresh_token` |
| `/oauth2/userinfo` | Point de terminaison UserInfo (jeton d'accès Bearer avec le scope `openid`) |
| `/saml-idp/<application_id>/metadata` | Métadonnées du fournisseur d'identité SAML 2.0 |
| `/saml-idp/<application_id>/sso` | Point de terminaison SSO SAML (liaisons HTTP-Redirect et HTTP-POST) |
| `/scim/v2/<directory_id>/…` | Points de terminaison SCIM 2.0 : `Users`, `Groups`, `ServiceProviderConfig`, `ResourceTypes`, `Schemas` |
| `/v1/…` | API de gestion en mémoire : chaque opération du document OpenAPI de l'API de gestion |

Les URL des points de terminaison du document de découverte et des métadonnées SAML pointent vers l'adresse locale sur laquelle écoute le serveur. `<application_id>` et `<directory_id>` sont des noms de votre choix (1 à 64 caractères parmi `A-Z`, `a-z`, `0-9`, `-`, `_`) ; rien n'a besoin d'être enregistré au préalable.

## Comportement

- **Aucun écran de consentement, aucune page de connexion.** Chaque requête d'autorisation valide est approuvée immédiatement pour un utilisateur de test fixe et fictif, puis redirigée avec `code`, `state` et `iss`. Le `sub` de l'utilisateur est `usr_01m3vnbwg0e8mbe7h7xxp6rmkc`, l'adresse e-mail `test.user@example.test` (vérifiée) et le nom `Test User`.
- **Aucun client enregistré.** Tout `client_id` est accepté. Les secrets client ne sont pas vérifiés. `redirect_uri` doit être une URI `https` absolue, ou une URI `http` sur un hôte de boucle locale (`127.0.0.1`, `[::1]`, `localhost`), sans fragment ; une `redirect_uri` invalide ou un `client_id` absent produit une page d'erreur au lieu d'une redirection.
- **PKCE est obligatoire.** `code_challenge` avec `code_challenge_method=S256` doit être envoyé ; `plain` est refusé. La requête de jeton doit envoyer le `code_verifier` correspondant.
- **Les codes sont à usage unique** et expirent au bout de 60 secondes. Présenter un code une seconde fois échoue avec `invalid_grant` et révoque tous les jetons émis à partir de celui-ci.
- **Jetons.** Le jeton d'accès est un JWT RFC 9068 (`typ` `at+jwt`) valable une heure, avec `sub`, `client_id`, `scope` et un `jti` aléatoire. Son audience est le paramètre `resource` de la requête d'autorisation, ou `https://test.keenauth.invalid/oauth2/userinfo` si aucun n'a été envoyé. Avec le scope `openid`, vous recevez aussi un jeton d'identité (avec `nonce` et `auth_time` ; les claims `email` et `name` avec les scopes `email` et `profile`). Avec `offline_access`, vous recevez un jeton de rafraîchissement.
- **Les jetons de rafraîchissement tournent.** Chaque rafraîchissement renvoie un nouveau jeton de rafraîchissement ; présenter un ancien échoue et révoque toute la chaîne. Un rafraîchissement peut restreindre le scope mais non l'élargir.
- **UserInfo** n'accepte que les jetons d'accès émis par ce processus, non expirés et non révoqués, et portant le scope `openid`.

## Jetons et votre vérificateur

Les jetons sont signés avec la clé publique de test du [kit de test hors ligne](/docs/sdk/testing) et portent l'émetteur `https://test.keenauth.invalid`, et non l'adresse locale. Le domaine de premier niveau `.invalid` est réservé : un vérificateur de production ne peut donc jamais accepter ces jetons. Les vérificateurs des SDK ne les acceptent qu'en mode test, que vous activez explicitement dans le code de test.

```go
// Go: accept tokens from the mock server in a test.
v, err := keenauthtest.NewVerifier(verify.Config{Audience: "https://test.keenauth.invalid/oauth2/userinfo"})
if err != nil {
	t.Fatal(err)
}
claims, err := v.VerifyAccessToken(ctx, accessToken, nil)
```

Utilisez le même mode test en TypeScript (`createTestVerifier`) et en Python (`keenauth.testing.verifier`). Les bibliothèques génériques de client OIDC qui exigent que l'`issuer` du document de découverte soit égal à l'URL à partir de laquelle il a été récupéré refuseront le document du serveur mock ; configurez-les directement avec les URL des points de terminaison, ou testez-les contre votre environnement `development`.

## Exemple : le flux complet avec curl

L'exemple nécessite `curl`, `openssl` et `jq`. Démarrez d'abord le serveur dans un autre terminal.

```sh
BASE=http://127.0.0.1:4010
REDIRECT=http://127.0.0.1:8080/callback
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

# 1. Authorize: the server answers with a redirect that carries the code.
LOCATION=$(curl -s -o /dev/null -w '%{redirect_url}' --get "$BASE/oauth2/authorize" \
  --data-urlencode response_type=code --data-urlencode client_id=app_local \
  --data-urlencode redirect_uri="$REDIRECT" --data-urlencode 'scope=openid email offline_access' \
  --data-urlencode state=xyz --data-urlencode code_challenge="$CHALLENGE" \
  --data-urlencode code_challenge_method=S256)
CODE=$(printf '%s' "$LOCATION" | sed -E 's/.*[?&]code=([^&]+).*/\1/')

# 2. Exchange the code for tokens.
TOKENS=$(curl -s "$BASE/oauth2/token" -d grant_type=authorization_code -d client_id=app_local \
  --data-urlencode code="$CODE" --data-urlencode redirect_uri="$REDIRECT" -d code_verifier="$VERIFIER")

# 3. Call UserInfo with the access token.
curl -s "$BASE/oauth2/userinfo" -H "Authorization: Bearer $(printf '%s' "$TOKENS" | jq -r .access_token)"

# 4. Refresh.
curl -s "$BASE/oauth2/token" -d grant_type=refresh_token -d client_id=app_local \
  --data-urlencode refresh_token="$(printf '%s' "$TOKENS" | jq -r .refresh_token)"
```

L'étape 3 affiche l'utilisateur de test :

```json
{"email":"test.user@example.test","email_verified":true,"sub":"usr_01m3vnbwg0e8mbe7h7xxp6rmkc"}
```

## Fournisseur d'identité SAML

Utilisez le serveur mock comme fournisseur d'identité d'un fournisseur de services SAML que vous développez : indiquez au fournisseur de services l'URL des métadonnées, connectez-vous, et le serveur mock répond par une réponse signée pour l'utilisateur de test. Les chemins suivent le fournisseur d'identité SAML hébergé que KeenAuth prévoit pour une version ultérieure.

- **Métadonnées.** `GET /saml-idp/<application_id>/metadata` renvoie un `EntityDescriptor` avec l'identifiant d'entité `https://test.keenauth.invalid/saml-idp/<application_id>`, le certificat de signature, les formats de NameID `persistent` et `emailAddress`, et le point de terminaison SSO pour les deux liaisons. Les AuthnRequest signées ne sont pas requises (`WantAuthnRequestsSigned="false"`) ; une signature sur la requête est ignorée.
- **SSO.** Envoyez l'AuthnRequest avec la liaison HTTP-Redirect (`GET`, DEFLATE + base64 dans `SAMLRequest`) ou la liaison HTTP-POST (`POST`, base64). Il n'y a pas de page de connexion : une requête valide est approuvée immédiatement pour l'utilisateur de test, et vous obtenez une page HTML qui poste elle-même `SAMLResponse` (et votre `RelayState`, 80 octets au maximum) vers l'URL ACS du fournisseur de services.
- **L'AuthnRequest doit contenir** `ID`, `Version="2.0"`, `IssueInstant`, un `saml:Issuer` (l'identifiant d'entité de votre fournisseur de services) et `AssertionConsumerServiceURL`, car le serveur mock n'a aucun fournisseur de services enregistré. L'URL ACS doit être une URL `https` absolue, ou une URL `http` sur un hôte de boucle locale, sans fragment. `ProtocolBinding`, s'il est présent, doit valoir HTTP-POST ; `Destination`, s'il est présent, doit être l'URL SSO. Toute autre requête reçoit `400` et n'est envoyée nulle part. Les DTD sont refusées.
- **La réponse** est signée deux fois avec RSA-SHA256 et une canonisation exclusive : l'assertion, puis la réponse entière. `InResponseTo` est l'`ID` de votre requête, `Destination` et le `Recipient` de la confirmation du sujet sont l'URL ACS, l'`Audience` est l'`Issuer` de votre requête, et l'assertion est valable à partir de son émission pendant 5 minutes. Le NameID est `persistent` (une valeur stable par application), sauf si la requête demande `emailAddress`. Les attributs sont `email`, `given_name`, `family_name` et `name`.

**La clé de signature est publique.** Elle est dérivée d'un libellé publié, donc n'importe qui peut signer des réponses que ce certificat vérifie. Son empreinte SHA-256 est `fcc2fc37b9ddb800721283156f9eb26ff1b80f099d996a65650f8cb469a9be81` et son sujet indique `TEST ONLY`. Ne l'enregistrez que dans les configurations de développement et de test de votre fournisseur de services, jamais en production. KeenAuth l'applique à ses propres connexions SAML : dans les environnements de staging et de production, il refuse cette clé et tout identifiant d'entité sous `.invalid`, à l'import des métadonnées ou à l'ajout d'un certificat, puis à nouveau à la connexion ; les environnements de développement les acceptent.

### Exemple : une connexion SAML avec curl

L'exemple nécessite `curl` et `openssl`. Démarrez d'abord le serveur dans un autre terminal.

```sh
BASE=http://127.0.0.1:4010
APP=app_local
ACS=http://127.0.0.1:8080/saml/acs

# 1. IdP metadata: entity ID, signing certificate and SSO URL for your service provider.
curl -s "$BASE/saml-idp/$APP/metadata" -o idp-metadata.xml

# 2. Send an AuthnRequest with the HTTP-POST binding (base64, no compression).
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
REQUEST=$(printf '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req1" Version="2.0" IssueInstant="%s" AssertionConsumerServiceURL="%s"><saml:Issuer>https://sp.example.com</saml:Issuer></samlp:AuthnRequest>' "$NOW" "$ACS" | openssl base64 -A)
curl -s "$BASE/saml-idp/$APP/sso" --data-urlencode SAMLRequest="$REQUEST" --data-urlencode RelayState=xyz -o form.html

# 3. The page posts SAMLResponse to the ACS URL by itself; decode it to look at the signed response.
sed -n 's/.*name="SAMLResponse" value="\([^"]*\)".*/\1/p' form.html | openssl base64 -d -A > response.xml
grep -o 'InResponseTo="[^"]*"' response.xml | head -1
```

La dernière commande affiche `InResponseTo="_req1"`. Votre fournisseur de services valide `response.xml` avec le certificat présent dans `idp-metadata.xml`.

## Points de terminaison SCIM 2.0

Utilisez les points de terminaison SCIM pour tester un client SCIM, tel qu'un connecteur de provisionnement ou un script qui crée des utilisateurs et des groupes, sans réseau. Chaque `<directory_id>` est un annuaire distinct et vide, qui vit aussi longtemps que le processus.

- **Authentification.** Chaque requête doit porter `Authorization: Bearer keenauth-mock-scim-token`. Toute autre requête reçoit `401` avec un défi `WWW-Authenticate: Bearer`. La valeur est fixe et publique ; ce n'est pas un jeton SCIM de KeenAuth.
- **Utilisateurs et groupes.** `POST`, `GET`, `PUT`, `PATCH` et `DELETE` sur `/Users` et `/Groups` (RFC 7644). `userName` et le `displayName` du groupe sont obligatoires et uniques dans l'annuaire, sans tenir compte de la casse ; un doublon reçoit `409` avec `scimType` `uniqueness`. Les membres d'un groupe doivent être des utilisateurs du même annuaire. Supprimer un utilisateur le retire de ses groupes. `password` est accepté et ignoré.
- **Filtres et pages.** `filter` prend en charge `eq`, `ne`, `co`, `sw`, `ew` et `pr`, reliés par `and` (par exemple `userName eq "ada@example.test"`), ainsi que `startIndex` et `count` (200 au maximum). `attributes` et `excludedAttributes` sélectionnent des attributs de premier niveau. `POST /Users/.search` et `/Groups/.search` acceptent les mêmes paramètres dans le corps.
- **PATCH** prend en charge `add`, `remove` et `replace` avec des chemins tels que `active`, `name.givenName`, `emails[type eq "work"].value`, `members[value eq "<id>"]` et les attributs de l'extension enterprise ; les opérations d'une même requête sont appliquées toutes ou aucune.
- **Versions.** Chaque ressource porte `meta.version` et un en-tête `ETag` (`W/"1"`, `W/"2"`, …). `If-Match` sur `PUT`, `PATCH` et `DELETE` renvoie `412` lorsque la version a changé ; `If-None-Match` sur `GET` renvoie `304`.
- **Découverte.** `ServiceProviderConfig`, `ResourceTypes` et `Schemas` décrivent exactement ce comportement. `/Bulk` et `/Me` renvoient `501`, comme dans KeenAuth.

### Exemple : provisionner un utilisateur avec curl

L'exemple nécessite `curl` et `jq`. Démarrez d'abord le serveur dans un autre terminal.

```sh
SCIM=http://127.0.0.1:4010/scim/v2/dir_local
AUTH='Authorization: Bearer keenauth-mock-scim-token'
CT='Content-Type: application/scim+json'

# 1. Create a user; the response carries the id, meta.version and an ETag header.
USER_ID=$(curl -s "$SCIM/Users" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "userName": "ada@example.test",
  "name": {"givenName": "Ada", "familyName": "Lovelace"},
  "emails": [{"value": "ada@example.test", "type": "work", "primary": true}]
}' | jq -r .id)

# 2. Find it with a filter.
curl -s -G "$SCIM/Users" -H "$AUTH" --data-urlencode 'filter=userName eq "ada@example.test"' | jq .totalResults

# 3. Deactivate it with PATCH, guarded by the version you read.
curl -s -X PATCH "$SCIM/Users/$USER_ID" -H "$AUTH" -H "$CT" -H 'If-Match: W/"1"' -d '{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [{"op": "replace", "path": "active", "value": false}]
}' | jq -c '{active, version: .meta.version}'

# 4. Create a group with the user as a member, then delete the user.
curl -s "$SCIM/Groups" -H "$AUTH" -H "$CT" -d '{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
  "displayName": "Engineering",
  "members": [{"value": "'"$USER_ID"'"}]
}' | jq -c '.members[0] | {display, type}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$SCIM/Users/$USER_ID" -H "$AUTH"
```

Les commandes affichent `1`, `{"active":false,"version":"W/\"2\""}`, `{"display":"ada@example.test","type":"User"}` et `204`.

## API de gestion (`/v1`)

Utilisez les points de terminaison de l'API de gestion pour tester, en CI et sans réseau, le code qui appelle l'API de gestion de KeenAuth (un SDK, la CLI, un script de déploiement). Les chemins sont ceux de l'environnement hébergé : utilisez l'URL de base `http://127.0.0.1:4010/v1` à la place de l'URL hébergée, rien d'autre ne change. Les points de terminaison sont générés à partir du document OpenAPI de l'API de gestion intégré à la CLI, donc chaque opération de cette version de l'API répond.

- **Authentification.** Chaque requête doit porter `Authorization: Bearer keenauth-mock-management-token`. Toute autre requête reçoit `401` avec un défi `WWW-Authenticate: Bearer`, y compris sur les chemins inconnus. La valeur est fixe et publique ; ce n'est pas une clé de gestion KeenAuth.
- **Réponses.** Chaque opération répond avec le code de succès que déclare le document (`200`, `201`, `202`, `204` ; les deux opérations de téléchargement répondent `302` avec un `Location` qui ne se résout pas). Le corps est l'`example` de la réponse de succès du document ou, tant qu'il n'y en a pas, le plus petit exemple valide construit à partir du schéma de réponse : seulement les champs obligatoires, les champs nullables à `null`, les énumérations avec leur première valeur, et les chaînes conformes à leur `pattern`.
- **État simple.** Une collection dans laquelle vous pouvez créer des enregistrements (`POST /v1/users`, `/v1/organizations`, `/v1/applications`, `/v1/organizations/<org_id>/roles`, …) les conserve : l'enregistrement créé est renvoyé par `GET` sur son propre chemin et dans la liste, `PATCH` et `PUT` le modifient, `DELETE` le supprime avec tout ce qui se trouve en dessous, et un chemin sous un enregistrement inexistant renvoie `404`. Les identifiants ont la forme à préfixe KeenAuth (`usr_…`, `org_…`, `app_…`) et respectent le motif du document ; lorsque le document n'en donne pas, le préfixe est déduit du nom du paramètre. Les réglages qui n'existent qu'une fois par environnement (par exemple `/v1/identity-settings`) conservent ce que vous avez écrit avec `PATCH` ou `PUT`. Les ressources qui ne peuvent pas être créées par l'API (par exemple les sessions et les rapports) répondent toujours avec l'exemple.
- **Listes.** `limit` (1 à 100, 20 par défaut), `after` ou `before` avec l'identifiant d'un enregistrement comme curseur, et `order` (`desc` par défaut, le plus récent d'abord) fonctionnent ; la réponse contient `list_metadata`. Les filtres tels que `status` ne sont pas appliqués.
- **Erreurs** : `application/problem+json` comme dans le document, avec `code` et un `request_id` également présent dans l'en-tête `Idp-Request-Id`. Un chemin inconnu renvoie `404 not_found`, une méthode que le chemin ne prévoit pas renvoie `405 method_not_allowed` avec un en-tête `Allow`, un corps qui n'est pas du JSON valide (ou qui n'est pas un objet) renvoie `400 malformed_request`, un `Content-Type` autre que JSON renvoie `415` sur les opérations qui le listent, un champ obligatoire manquant renvoie `422 validation_failed` avec `errors[]`, un corps de plus de 1 MiB renvoie `413`, et un `limit` ou un curseur invalide renvoie `400`.

**Ce qui diffère de l'environnement `development`.** Le serveur mock vérifie seulement que le corps est un objet JSON contenant les champs obligatoires ; les formats de champs, les longueurs, les énumérations et l'unicité ne sont pas contrôlés, et un champ au mauvais type est ignoré. Il ignore `If-Match`, `Idempotency-Key` et les autres en-têtes et ne renvoie pas d'`ETag`. Il ne contrôle ni les permissions, ni les limites de forfait, ni les quotas, ni les limites de débit, ni la région ; il ne masque pas les données personnelles, n'envoie aucun e-mail, n'exécute aucune tâche (une tâche acceptée reste dans l'état où elle a été créée) et ne dispose que d'un seul environnement. Les enregistrements n'existent que pendant la durée du processus, au maximum 10 000. Utilisez votre environnement `development` pour savoir comment se comporte la véritable API.

### Exemple : créer, lire, modifier et supprimer avec curl

L'exemple nécessite `curl` et `jq`. Démarrez d'abord le serveur dans un autre terminal.

```sh
API=http://127.0.0.1:4010/v1
AUTH='Authorization: Bearer keenauth-mock-management-token'
CT='Content-Type: application/json'

# 1. Create an organization.
ORG_ID=$(curl -s "$API/organizations" -H "$AUTH" -H "$CT" -d '{"name": "Acme"}' | jq -r .id)

# 2. Read it back and list.
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{id: (.id | .[0:4]), name}'
curl -s "$API/organizations" -H "$AUTH" | jq -c '{count: (.data | length), list_metadata}'

# 3. Rename it with a merge patch.
curl -s -X PATCH "$API/organizations/$ORG_ID" -H "$AUTH" -H 'Content-Type: application/merge-patch+json' -d '{"name": "Acme Inc"}' | jq -r .name

# 4. Delete it; a second read is a 404 problem.
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$API/organizations/$ORG_ID" -H "$AUTH"
curl -s "$API/organizations/$ORG_ID" -H "$AUTH" | jq -c '{status, code}'

# 5. Without the token.
curl -s "$API/organizations" | jq -c '{status, code}'
```

Les commandes affichent `{"id":"org_","name":"Acme"}`, `{"count":1,"list_metadata":{"after":null,"before":null}}`, `Acme Inc`, `204`, `{"status":404,"code":"not_found"}` et `{"status":401,"code":"unauthenticated"}`.

## Non pris en charge

Il n'y a pas d'horloge de test, ni DPoP, ni PAR, ni objets de requête, ni flux device, ni client credentials, ni échange de jetons, ni déconnexion ou révocation, ni pages hébergées. Le fournisseur d'identité SAML n'a ni déconnexion unique, ni assertions chiffrées, ni liaison par artefact, ni connexion initiée par l'IdP. Les points de terminaison SCIM n'ont ni opérations en masse, ni tri, ni `/Me`, ni groupes imbriqués, et le filtre ne connaît ni `or`, ni `not`, ni parenthèses, ni `gt`/`ge`/`lt`/`le`. Utilisez votre environnement `development` pour les tester.
