Skip to Content
SimulatorsLinearOAuth applications

Simulators

OAuth applications

An OAuth2 application signs in to the simulator as it does to Linear, through three routes:

  • GET /oauth/authorize gives consent.
  • POST /oauth/token handles the authorization code, refresh token and client credentials grants.
  • POST /oauth/revoke ends 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

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, with WWW-Authenticate: Basic realm="Service".
  • POST /oauth/revoke with no token: 401. A token it never issued: 401 “Token not found”. token beside a legacy field, or both legacy fields: 400.
  • A personal key sent under Bearer: 400 INPUT_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.

Last updated on