Connect your app

Local OAuth

Use Calendarpit's local OAuth 2.0 authorization code flow, token endpoints, and supported scopes.

Calendarpit includes a local OAuth 2.0 authorization server. Use it to test your app without a Google Cloud project or real Google accounts.

During authorization, you pick a test user from a dropdown on the local consent page. You do not enter a password.

Before you start the flow, point your app's OAuth client and Google Calendar SDK at http://127.0.0.1:4173. See Connect your app for endpoint configuration.

Endpoints

MethodPathPurpose
GET/oauth/authorizeValidate the authorization request and render the local HTML consent page
POST/oauth/authorizeSubmit the consent decision (approve or deny) and redirect (302) to redirect_uri
POST/oauth/tokenExchange an authorization code or refresh token for an access token
POST/oauth/revokeRevoke an access token or refresh token and its connection
GET/userinfoReturn profile claims for the authorized test user
POST/__calendarpit/connections/{grantId}/revokeRevoke a connection from the management API (204)
  • POST /oauth/authorize accepts same-origin requests only. Cross-site requests return 403 forbidden.
  • POST /__calendarpit/connections/{grantId}/revoke follows the management API request rules. See Test control API.

Only the paths above are routed. These requests return 501:

  • Google endpoints under /oauth2/..., such as /oauth2/v4/token or /oauth2/v3/userinfo
  • Unsupported HTTP methods on /oauth/*

The 501 response body is {"error":"unsupportedFeature","error_description":"Unsupported API endpoint or method."}.

Authorization code flow

  1. Register an OAuth client in your fixture. Add an entry with id, secret, and redirectUris to the startup fixture's clients array. Each redirect URI must be an absolute http: or https: URL with no fragment or userinfo.
    • The startup fixture seeds only a new database or a :memory: session.
    • For an existing database file, load the fixture with POST /__calendarpit/fixtures, or start with a new database. Loading a fixture replaces workspace data until the next reset. See Fixtures and Storage and restarts.
    • The default fixture includes the client sample-app with secret local-sample-secret and redirect URI http://127.0.0.1:4175/callback. Calendarpit uses the default fixture when neither --config nor CALENDARPIT_CONFIG is set.
  2. Send the browser to GET /oauth/authorize. Build a URL at http://127.0.0.1:4173/oauth/authorize with URL-encoded query parameters:
    • client_id (required): Registered client ID. Unknown IDs return 400 invalid_client.
    • redirect_uri (required): Callback URL. It must match one of the client's redirectUris by exact string comparison. There are no loopback port wildcards and no trailing-slash normalization. Mismatches return 400 invalid_request.
    • response_type (required): Must be code.
    • scope (required): Space-separated list of supported scopes. See Scopes.
    • state (optional): Random value tied to the user's browser session. When provided, it is echoed back on the redirect.
    • access_type (optional): online or offline. Set access_type=offline to receive a refresh token.
    • code_challenge and code_challenge_method (optional): PKCE parameters. When used, code_challenge_method must be S256. code_challenge must be a 43-character base64url digest.
    • prompt (optional): Accepts consent, select_account, consent select_account, or select_account consent. The consent page is always displayed.
    • include_granted_scopes (optional): Must be false if present.
  3. Approve or deny on the consent page. The page lists all workspace test users (Name ยท email) and the requested scope strings. Choose a test user and click Approve or Deny:
    • Approve creates a new connection (grant) and redirects (302) to <redirect_uri>?state=<state>&code=<code>.
    • Deny redirects (302) to <redirect_uri>?state=<state>&error=access_denied.
  4. Verify state in your callback handler. Compare the state query parameter with the session value you stored before redirecting. If error=access_denied is present, stop and do not call /oauth/token.
  5. Exchange the authorization code at POST /oauth/token. Send an application/x-www-form-urlencoded body. Include grant_type=authorization_code, code, redirect_uri (identical to step 2), client credentials, and code_verifier if you used PKCE. For credentials, see Client authentication. The endpoint returns JSON:
    {
      "access_token": "...",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "https://www.googleapis.com/auth/calendar email profile",
      "refresh_token": "..."
    }
    refresh_token is present only when the authorization request included access_type=offline.
  6. Call the API with the access token. Pass Authorization: Bearer <access_token> on requests to /calendar/v3/... and GET /userinfo. See SDK examples.

HTTP examples

These examples use shell variables for your client values and tokens:

  • PIT_CLIENT_ID, PIT_CLIENT_SECRET, and PIT_REDIRECT_URI: your client's values.
  • PIT_CODE: the code that your callback received.
  • PIT_REFRESH_TOKEN: the refresh_token returned by the token exchange.
  • CALENDARPIT_ACCESS_TOKEN: the access_token returned by the token exchange.

If you used PKCE, also pass --data-urlencode "code_verifier=$PIT_VERIFIER" when you exchange the code.

The Profile request requires openid, email, profile, or one of the userinfo.* scopes.

curl --fail-with-body http://127.0.0.1:4173/oauth/token \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "client_id=$PIT_CLIENT_ID" \
  --data-urlencode "client_secret=$PIT_CLIENT_SECRET" \
  --data-urlencode "redirect_uri=$PIT_REDIRECT_URI" \
  --data-urlencode "code=$PIT_CODE"
curl --fail-with-body http://127.0.0.1:4173/oauth/token \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode "client_id=$PIT_CLIENT_ID" \
  --data-urlencode "client_secret=$PIT_CLIENT_SECRET" \
  --data-urlencode "refresh_token=$PIT_REFRESH_TOKEN"
curl --fail-with-body http://127.0.0.1:4173/userinfo \
  -H "Authorization: Bearer $CALENDARPIT_ACCESS_TOKEN"
curl --fail-with-body http://127.0.0.1:4173/oauth/revoke \
  --data-urlencode "token=$CALENDARPIT_ACCESS_TOKEN"

Client authentication, lifetimes, and revocation

Client authentication

Every POST /oauth/token call requires client authentication. This applies to both authorization_code and refresh_token, even when you use PKCE. Public clients without a secret are not supported.

You can supply credentials in two ways:

  • Form fields (client_secret_post): Include client_id and client_secret in the URL-encoded body.
  • HTTP Basic (client_secret_basic): Send Authorization: Basic <base64(urlencode(client_id):urlencode(client_secret))>.

If you send both, the Authorization header takes precedence.

POST /oauth/token always parses the body as application/x-www-form-urlencoded, regardless of the Content-Type header. It does not read JSON bodies. Sending JSON fails with 400 invalid_client, or with 400 unsupported_grant_type when credentials come from HTTP Basic.

Lifetimes and persistence

ItemLifetimeSingle usePersisted in SQLite across restart
Pending consent nonce5 minutesYesNo
Authorization code60 secondsYesNo
Access token1 hour (expires_in: 3600)NoYes (with original expiry)
Refresh tokenNo expiry until revoked, user deleted, reset, or fixture loadNoYes
Connection (grant)Until revoked, user deleted, reset, or fixture loadNoYes
  • At most 1,000 consents can be pending. Above that, the response is 429 rateLimitExceeded.
  • An authorization code is bound to its client_id and redirect_uri.
  • In :memory: mode, all OAuth state is lost when the process exits. See Storage and restarts.

Refresh

grant_type=refresh_token issues a new access_token (expires_in: 3600).

  • It does not return or rotate the refresh_token. Your existing refresh token stays valid.
  • If you include scope in the refresh request, it must equal the original space-joined scope string exactly. Otherwise the call fails with 400 invalid_scope.

Revocation

To revoke a token, call POST /oauth/revoke:

  • Pass an access token or refresh token in the token form field, or in the ?token= query parameter.
  • Client authentication is not required.
  • Revoking either token revokes the entire grant. It deletes all of the grant's access tokens, refresh tokens, and authorization codes, and stops its watch channels.
  • An unknown token returns 200 with an empty body.
  • A missing or blank token returns 400 invalidParameter.

Other actions also invalidate tokens:

  • Revoke a connection in the admin UI (POST /__calendarpit/connections/{grantId}/revoke): Invalidates the connection's tokens and stops its watch channels.
  • Delete a test user (DELETE /__calendarpit/users/{id}): Invalidates the user's tokens and stops their watch channels.
  • Reset or load a fixture (POST /__calendarpit/reset or POST /__calendarpit/fixtures): Deletes all connections, tokens, authorization codes, and pending consents, and stops all watch channels.

See Test control API.

Scopes

Every GET /oauth/authorize request must include at least one supported scope. Requesting any other scope returns 400 invalid_scope.

Calendar API scopes

ScopeAllowed operations
https://www.googleapis.com/auth/calendarAll supported Calendar API reads and writes (calendarList.*, calendars.get, events.*, channels.stop, freebusy.query)
https://www.googleapis.com/auth/calendar.readonlycalendarList.list, calendarList.get, calendarList.watch, calendars.get, events.list, events.get, events.instances, events.watch, channels.stop, freebusy.query
https://www.googleapis.com/auth/calendar.eventsevents.list, events.get, events.instances, events.insert, events.update, events.patch, events.delete, events.watch, channels.stop, freebusy.query
https://www.googleapis.com/auth/calendar.events.readonlyevents.list, events.get, events.instances, events.watch, channels.stop, freebusy.query
https://www.googleapis.com/auth/calendar.calendarlistcalendarList.list, calendarList.get, calendarList.insert, calendarList.patch, calendarList.update, calendarList.delete, calendarList.watch, channels.stop
https://www.googleapis.com/auth/calendar.calendarlist.readonlycalendarList.list, calendarList.get, calendarList.watch, channels.stop
https://www.googleapis.com/auth/calendar.calendars.readonlycalendars.get
https://www.googleapis.com/auth/calendar.freebusyfreebusy.query
https://www.googleapis.com/auth/calendar.events.freebusyfreebusy.query

Profile scopes

These scopes allow GET /userinfo. The response always contains { sub, id }, both set to the test user ID.

ScopeGET /userinfo response
openid{ sub, id }
email, https://www.googleapis.com/auth/userinfo.emailAdds email, verified_email: true, and email_verified: true
profile, https://www.googleapis.com/auth/userinfo.profileAdds name

openid only grants access to GET /userinfo with { sub, id }. Calendarpit does not:

  • return an id_token from /oauth/token
  • serve /.well-known/openid-configuration or JWKS
  • include picture, given_name, family_name, or locale in /userinfo

Token and scope errors

ConditionResponse
Missing, expired, or revoked access token401 (authError) with a WWW-Authenticate: Bearer header
Valid token missing a required scope for the endpoint403 (insufficientPermissions)
channels.stop with a token from a different connection404 (notFound)

channels.stop requires the token to belong to the same connection (grant) that opened the channel.

Unlike /oauth/* endpoints, GET /userinfo and Calendar API endpoints return errors in the Google API error envelope. See Troubleshooting.

Unsupported OAuth features

GET /oauth/authorize returns validation errors directly to the browser. It does not redirect to redirect_uri. The response is 400 JSON: {"error": "<reason>", "error_description": "<message>"}.

  • Unknown client_id: 400 invalid_client ("Unknown client.").
  • Unregistered redirect_uri: 400 invalid_request ("Unregistered redirect URI."). This covers any value that does not match one of the client's redirectUris by exact string equality.
  • Unknown authorize parameters: 400 unsupported_feature. Examples are nonce, login_hint, hd, request, request_uri, and enable_granular_consent.
  • Non-interactive consent: 400 unsupported_feature. This covers prompt=none and any prompt value other than the four listed in step 2.
  • Incremental authorization (include_granted_scopes=true): 400 unsupported_feature.
  • Other response types (anything other than response_type=code): 400 unsupported_response_type.
  • PKCE plain method: 400 invalid_request.
  • Unlisted scopes: 400 invalid_scope. Examples are calendar.events.owned, calendar.app.created, calendar.settings.readonly, calendar.acls, and the write scope calendar.calendars.

Other OAuth limits:

  • Other token grant types: POST /oauth/token returns 400 unsupported_grant_type after client authentication succeeds.
  • API keys (key= query parameter): Accepted as a system parameter on API requests, but never replace bearer token authentication.

See Scope and limitations for a summary of all unsupported Google features.

On this page