docs: record backend auth model
This commit is contained in:
parent
4f8a2357a5
commit
026e4cf90b
|
|
@ -8549,6 +8549,131 @@ mod tests {
|
|||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_passkey_session_and_device_login_flow_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
|
||||
let registration_options = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/passkeys/registration/options",
|
||||
json!({
|
||||
"handle": "alice",
|
||||
"display_name": "Alice"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let registration_challenge = registration_options["challenge"].as_str().unwrap();
|
||||
let registered = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
json!({
|
||||
"challenge": registration_challenge,
|
||||
"credential_id": "credential-1",
|
||||
"public_key_cose": "public-key-cose",
|
||||
"transports": ["internal"]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(registered["user"]["handle"], "alice");
|
||||
|
||||
let login_options = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/passkeys/login/options",
|
||||
json!({ "handle": "alice" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
login_options["allow_credentials"][0]["credential_id"],
|
||||
"credential-1"
|
||||
);
|
||||
let login_challenge = login_options["challenge"].as_str().unwrap();
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/passkeys/login/complete")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"challenge": login_challenge,
|
||||
"credential_id": "credential-1"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(login_response.status(), StatusCode::OK);
|
||||
let cookie = login_response
|
||||
.headers()
|
||||
.get(SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let login_bytes = to_bytes(login_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let login_json: Value = serde_json::from_slice(&login_bytes).unwrap();
|
||||
assert_eq!(login_json["user"]["handle"], "alice");
|
||||
|
||||
let device = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/device-login/start",
|
||||
json!({ "client_name": "test cli" }),
|
||||
)
|
||||
.await;
|
||||
let approved = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/device-login/approve")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("Cookie", cookie)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"user_code": device["user_code"].as_str().unwrap()
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(approved.status(), StatusCode::OK);
|
||||
|
||||
let polled = post_json(
|
||||
app.clone(),
|
||||
"/api/auth/device-login/poll",
|
||||
json!({ "device_code": device["device_code"].as_str().unwrap() }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(polled["status"], "approved");
|
||||
let access_token = polled["access_token"].as_str().unwrap();
|
||||
|
||||
let whoami = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/whoami")
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(whoami.status(), StatusCode::OK);
|
||||
let whoami_bytes = to_bytes(whoami.into_body(), usize::MAX).await.unwrap();
|
||||
let whoami_json: Value = serde_json::from_slice(&whoami_bytes).unwrap();
|
||||
assert_eq!(whoami_json["actor"]["handle"], "alice");
|
||||
assert_eq!(whoami_json["actor"]["auth_method"], "api_token");
|
||||
}
|
||||
|
||||
async fn get_json(app: Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
|
|
|
|||
55
docs/backend-auth-account-model.md
Normal file
55
docs/backend-auth-account-model.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Backend account model and login flows
|
||||
|
||||
This document records the first Backend identity/auth layer introduced for the multi-workspace Backend direction.
|
||||
|
||||
## Account and owner model
|
||||
|
||||
The Backend store now separates a human user from the owner namespace used by workspace ownership:
|
||||
|
||||
- `accounts`: canonical namespace records. `kind = "user"` is implemented now; `kind = "organization"` is reserved in the schema for a later organization model.
|
||||
- `users`: human user records. Each user owns exactly one `accounts` row.
|
||||
- `workspaces.owner_account_id`: optional account owner reference. Existing/local workspaces may keep it unset until account bootstrap/ownership migration code assigns it.
|
||||
|
||||
This avoids baking `owner_user_id` into workspace authority while keeping the current implementation small.
|
||||
|
||||
## Browser login
|
||||
|
||||
Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints:
|
||||
|
||||
- `POST /api/auth/bootstrap-user`
|
||||
- `POST /api/auth/passkeys/registration/options`
|
||||
- `POST /api/auth/passkeys/registration/complete`
|
||||
- `POST /api/auth/passkeys/login/options`
|
||||
- `POST /api/auth/passkeys/login/complete`
|
||||
- `GET /api/auth/whoami`
|
||||
|
||||
The store persists registration/login challenges, passkey credential IDs, COSE public key payloads, transports, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session.
|
||||
|
||||
Current boundary: the API and persistence shape are WebAuthn-oriented, but cryptographic attestation/assertion verification is not implemented in this step. The completion endpoints consume the server-issued challenge and validate credential/user binding. A follow-up should wire a WebAuthn verifier crate and replace the provisional completion payloads with verified browser `PublicKeyCredential` responses before this is exposed outside trusted development use.
|
||||
|
||||
## CLI/TUI device login
|
||||
|
||||
CLI/TUI clients use a device-login flow instead of browser cookies:
|
||||
|
||||
- `POST /api/auth/device-login/start` creates a `device_code` and human `user_code`.
|
||||
- The CLI prints `verification_uri_complete` and polls `POST /api/auth/device-login/poll`.
|
||||
- A browser-authenticated user approves with `POST /api/auth/device-login/approve`.
|
||||
- Approval mints a Backend API token and returns it exactly once to the polling client.
|
||||
|
||||
`yoi login [--backend <URL>] [--no-wait]` starts this flow and stores the received token in `$XDG_CONFIG_HOME/yoi/backend-tokens.json` or `$HOME/.config/yoi/backend-tokens.json`.
|
||||
|
||||
## Request actor extraction
|
||||
|
||||
`workspace-server::auth::resolve_request_actor` resolves a typed request actor from either:
|
||||
|
||||
- `Authorization: Bearer <token>` for CLI/TUI API tokens, or
|
||||
- the configured browser session cookie.
|
||||
|
||||
The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, and `auth_method`. This helper is intended to be used by mutating Backend APIs as authorization gets added route-by-route.
|
||||
|
||||
## Non-goals for this step
|
||||
|
||||
- Organization membership and RBAC.
|
||||
- Password login, OAuth login, or other human auth methods.
|
||||
- Token scoping beyond Backend API bearer authentication.
|
||||
- Production WebAuthn cryptographic verification; the schema/API are prepared for it, but the verifier integration remains follow-up work.
|
||||
Loading…
Reference in New Issue
Block a user