Coaches log in with Google, and the server gives them its own tokens. Today `login` answers `NOT_IMPLEMENTED`, `me` always returns `null`, and every query about gymnasts and routines answers `UNAUTHENTICATED` (PR #65). This issue builds the login and the session, so the user-data resolvers can know who is asking. It is described in "Identifying the user" in `docs/API.md` and "Google login and `googleId`" in `docs/DATA_MODEL.md`. ## Scope - **`login(googleToken)`.** Verifies the Google ID token (signature, expiry, issuer, and `aud` equal to `GOOGLE_CLIENT_ID`), creates the `Coach` on the first login from the token's `sub` (`googleId`), `email` and `name`, and returns `AuthPayload` with the tokens and the coach. The Google token is never stored. A wrong or expired token gives `UNAUTHENTICATED`. - **Libraries.** `google-auth-library` (`OAuth2Client.verifyIdToken`) checks the Google token, and `jose` signs and verifies the access token. The rest (`login`, `refreshSession`, `logout`, the `sessions` table) is written by hand. A full auth framework (Better Auth, Auth.js) or a hosted service (Auth0, Clerk, Firebase) was considered and rejected: they bring their own tables and endpoints, mostly cookie sessions that do not fit `login`/`me` in our schema, or an external service outside the VM. - **Two tokens** (the usual setup, see OAuth 2.0 and OWASP): - **Access token:** a signed JWT (signed with `SESSION_SECRET`) with the coach id and an expiry of **15 minutes**. The client sends it in `Authorization: Bearer <token>`. The server reads it once per request and puts the coach in the request context, next to the DataLoaders. - **Refresh token:** a long random value (not a JWT) with a lifetime of **14 days**. Only a hash of it is stored in the database, in a new table `sessions` (id, coach id, token hash, expiry, revoked at, created at), with a migration. The client uses it to get a new access token without a new Google login. - **Rotation.** Every use of a refresh token returns a new access token and a **new** refresh token, and the old one stops working. If an old (already used) refresh token is used again, someone has copied it: the server revokes the whole chain of that login, and the coach has to log in again. - **`logout`** revokes the refresh token on the server. Expired and revoked rows are deleted now and then. - **Schema.** `AuthPayload` gets `refreshToken`, and two mutations are added: `refreshSession(refreshToken: String!): AuthPayload!` (a wrong, expired, revoked or reused token gives `UNAUTHENTICATED`) and `logout(refreshToken: String!): Boolean!`. Document them in `schema.graphql` and `docs/API.md`. - **Where the client keeps the tokens.** Decide and write it in `docs/API.md`. The access token is kept in memory only. The refresh token is safest in an httpOnly, Secure, SameSite cookie, which works when the app and the API are on the same origin (Apache proxy on the VM, the Vite proxy in development); otherwise it is kept in the client, and the rotation and the reuse check are the protection. - **`me`.** Returns the logged-in coach, or `null` when nobody is logged in or the access token is missing, wrong or expired (this is a normal state, see `docs/API.md`). Every other protected operation throws `UNAUTHENTICATED`, and the client then calls `refreshSession` once and repeats the request. - **Configuration.** `GOOGLE_CLIENT_ID` and `SESSION_SECRET` in `backend/.env.example` and the README. The server refuses to start without `SESSION_SECRET` when login is enabled, and the secret is never logged. - **Security.** Tokens are only read from the `Authorization` header (or the cookie), and only `Coach.id` goes into the access token. The refresh token is compared by its hash. No stack traces or token contents in errors. CORS still allows only the configured origin (`CORS_ORIGIN`). - **Tests.** The Google check is replaced by a fake in tests, and time is controlled: - first login creates the coach, a second login finds the same coach; - a wrong audience, an expired Google token and a broken signature give `UNAUTHENTICATED`; - an expired or tampered access token makes `me` return `null`, and a protected query without a token gives `UNAUTHENTICATED`; - `refreshSession` returns new tokens and the old refresh token stops working; - reusing an old refresh token revokes the whole chain; - an expired, revoked or unknown refresh token, and a token after `logout`, give `UNAUTHENTICATED`. - **Docs.** `docs/API.md` (the flow, the header, the lifetimes, where the client keeps the tokens, the variables) and the README (how to get a Google client id and try the login in development). This replaces the open question about the lifetime in `docs/API.md`. ## Done when - [ ] `login` with a valid Google token returns the tokens and the coach, and creates the coach only once - [ ] `me` returns the coach with a valid access token, and `null` without one or with a bad one - [ ] A protected operation without a valid access token throws `UNAUTHENTICATED` - [ ] `refreshSession` gives a new access token and a new refresh token, and the old refresh token stops working - [ ] Reusing an old refresh token revokes that login, and `logout` revokes the refresh token - [ ] The Google token is verified against `GOOGLE_CLIENT_ID` and is never stored or logged - [ ] Only a hash of the refresh token is stored, and the new `sessions` table has a migration - [ ] The lifetimes (15 minutes and 14 days) and where the client keeps the tokens are written in `docs/API.md` - [ ] The tests above pass, and lint, typecheck and build pass - [ ] `backend/.env.example`, the README and `docs/API.md` explain the setup ## Not in this issue - The resolvers for gymnasts, routines and their lists, and `Routine.dScore` (separate issue) - The login button and the token handling in the frontend (with Apollo Client): it must call `refreshSession` when the access token has expired Depends on the GraphQL server (#28, PR #65).