Simulators
OAuth applications
An OAuth2 application signs in to the simulator as it does to Linear, through three routes:
GET /oauth/authorizegives consent.POST /oauth/tokenhandles the authorization code, refresh token and client credentials grants.POST /oauth/revokeends a token.
A token goes in the Bearer Authorization header and acts as the user who consented, or as the application’s own user for an app token.
The simulator creates no applications, so seed yours. Give it a client id and secret, its redirect URIs, whether client_credentials is on, the user its app tokens act as (app_actor) and the user who approves consent (consent_actor):
curl -s "$RYSTIC_URL/_rystic/seed" -H 'Content-Type: application/json' -o /dev/null -w '%{http_code}\n' -d '{"state_user": {"5b0a4d00-05e2-4a00-8b00-0000000000aa": {"id": "5b0a4d00-05e2-4a00-8b00-0000000000aa", "name": "Ana Ruiz", "displayName": "ana", "email": "ana@example.com", "active": true}}, "state_oauth_application": {"app_1": {"id": "app_1", "name": "Triage bot", "client_id": "cid_triage", "client_secret": "cs_triage", "redirect_uris": ["http://localhost:3000/callback"], "client_credentials": true, "app_actor": "5b0a4d00-05e2-4a00-8b00-0000000000aa", "consent_actor": "5b0a4d00-05e2-4a00-8b00-0000000000aa"}}}'
# -> 204
Consent and the code grant
GET /oauth/authorize with a registered redirect_uri approves at once and redirects there with a code and your state. The consenting user is the request’s X-Rystic-Actor, or else the application’s consent_actor. Exchange the code once, as a form, with the client’s secret, or with the code_verifier under PKCE (plain or S256):
CODE=$(curl -s -o /dev/null -w '%{redirect_url}' "$RYSTIC_URL/oauth/authorize?client_id=cid_triage&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&scope=read,write&state=xyz" | sed 's/.*code=\([^&]*\).*/\1/')
curl -s "$RYSTIC_URL/oauth/token" -d grant_type=authorization_code -d code="$CODE" -d redirect_uri=http://localhost:3000/callback \
-d client_id=cid_triage -d client_secret=cs_triage > grant.json
jq -c '{token_type, expires_in, scope, access: (.access_token | startswith("lin_oauth_")), refresh: (.refresh_token | startswith("lin_refresh_"))}' grant.json
# -> {"token_type":"Bearer","expires_in":86399,"scope":"read write","access":true,"refresh":true}
curl -s "$RYSTIC_URL/graphql" -H "Authorization: Bearer $(jq -r .access_token grant.json)" -H 'Content-Type: application/json' \
-d '{"query":"{ viewer { name } }"}' | jq -c .data
# -> {"viewer":{"name":"Ana Ruiz"}}
A spent, unknown or expired code (codes live 10 minutes), or another redirect_uri, returns 400 invalid_grant.
Refresh and its 30-minute replay
A refresh token is single-use: it mints a new access token and a new refresh token. The client authenticates in the form or with HTTP Basic. Replaying a spent refresh token within 30 minutes returns the same new pair again, so a client that lost the response can recover it. After 30 minutes the replay returns 400 invalid_grant, “Invalid grant: refresh token is invalid”:
R=$(jq -r .refresh_token grant.json)
FIRST=$(curl -s "$RYSTIC_URL/oauth/token" -u cid_triage:cs_triage -d grant_type=refresh_token -d refresh_token="$R" | jq -r .access_token)
AGAIN=$(curl -s "$RYSTIC_URL/oauth/token" -u cid_triage:cs_triage -d grant_type=refresh_token -d refresh_token="$R" | jq -r .access_token)
[ "$FIRST" = "$AGAIN" ] && echo same pair
# -> same pair
An access token lives 24 hours (expires_in 86399) and an app token 30 days, both on the simulator clock. Move the clock with POST /_rystic/clock to test expiry without waiting.
App tokens
With client_credentials on, the grant mints an app token that acts as app_actor and comes with no refresh token. Asking again under other scopes revokes the application’s earlier app tokens, as Linear does:
curl -s "$RYSTIC_URL/oauth/token" -d grant_type=client_credentials -d scope=read,write -d client_id=cid_triage -d client_secret=cs_triage > app.json
jq -c '{token_type, expires_in, scope, refresh_token}' app.json
# -> {"token_type":"Bearer","expires_in":2591999,"scope":"read write","refresh_token":null}
Revocation and a dead token
POST /oauth/revoke takes the token in the token field, in one legacy access_token or refresh_token field or as the Bearer. It ends the token and its refresh token. A refresh token never authenticates a request. A revoked or expired token then returns Linear’s 401 AUTHENTICATION_ERROR on every request:
curl -s "$RYSTIC_URL/oauth/revoke" -d token="$(jq -r .access_token app.json)" -o /dev/null -w '%{http_code}\n'
# -> 200
curl -s "$RYSTIC_URL/graphql" -H "Authorization: Bearer $(jq -r .access_token app.json)" -H 'Content-Type: application/json' \
-d '{"query":"{ viewer { name } }"}' | jq -c '.errors[0].extensions.code'
# -> "AUTHENTICATION_ERROR"
These are the other refusals Linear returns:
- A token body that is not a form: 400
invalid_request. - No client: 400
invalid_client, “Invalid client: cannot retrieve client credentials”. - An unknown client or a wrong secret: 400
invalid_client, “Invalid client: client is invalid”. Under HTTP Basic it is 401, withWWW-Authenticate: Basic realm="Service". POST /oauth/revokewith no token: 401. A token it never issued: 401 “Token not found”.tokenbeside a legacy field, or both legacy fields: 400.- A personal key sent under
Bearer: 400INPUT_ERROR, “It looks like you’re trying to use an API key as a Bearer token.”
A token the simulator did not issue is still accepted under Bearer, as every placeholder key is. The responses to an already revoked token, an unknown grant type, an invalid code and an application without client_credentials are not yet confirmed against Linear: their status and wording may differ.