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
| Method | Path | Purpose |
|---|---|---|
GET | /oauth/authorize | Validate the authorization request and render the local HTML consent page |
POST | /oauth/authorize | Submit the consent decision (approve or deny) and redirect (302) to redirect_uri |
POST | /oauth/token | Exchange an authorization code or refresh token for an access token |
POST | /oauth/revoke | Revoke an access token or refresh token and its connection |
GET | /userinfo | Return profile claims for the authorized test user |
POST | /__calendarpit/connections/{grantId}/revoke | Revoke a connection from the management API (204) |
POST /oauth/authorizeaccepts same-origin requests only. Cross-site requests return403 forbidden.POST /__calendarpit/connections/{grantId}/revokefollows 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/tokenor/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
- Register an OAuth client in your fixture. Add an entry with
id,secret, andredirectUristo the startup fixture'sclientsarray. Each redirect URI must be an absolutehttp:orhttps: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-appwith secretlocal-sample-secretand redirect URIhttp://127.0.0.1:4175/callback. Calendarpit uses the default fixture when neither--confignorCALENDARPIT_CONFIGis set.
- The startup fixture seeds only a new database or a
- Send the browser to
GET /oauth/authorize. Build a URL athttp://127.0.0.1:4173/oauth/authorizewith URL-encoded query parameters:client_id(required): Registered client ID. Unknown IDs return400 invalid_client.redirect_uri(required): Callback URL. It must match one of the client'sredirectUrisby exact string comparison. There are no loopback port wildcards and no trailing-slash normalization. Mismatches return400 invalid_request.response_type(required): Must becode.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):onlineoroffline. Setaccess_type=offlineto receive a refresh token.code_challengeandcode_challenge_method(optional): PKCE parameters. When used,code_challenge_methodmust beS256.code_challengemust be a 43-character base64url digest.prompt(optional): Acceptsconsent,select_account,consent select_account, orselect_account consent. The consent page is always displayed.include_granted_scopes(optional): Must befalseif present.
- 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.
- Approve creates a new connection (grant) and redirects (
- Verify
statein your callback handler. Compare thestatequery parameter with the session value you stored before redirecting. Iferror=access_deniedis present, stop and do not call/oauth/token. - Exchange the authorization code at
POST /oauth/token. Send anapplication/x-www-form-urlencodedbody. Includegrant_type=authorization_code,code,redirect_uri(identical to step 2), client credentials, andcode_verifierif 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_tokenis present only when the authorization request includedaccess_type=offline. - Call the API with the access token. Pass
Authorization: Bearer <access_token>on requests to/calendar/v3/...andGET /userinfo. See SDK examples.
HTTP examples
These examples use shell variables for your client values and tokens:
PIT_CLIENT_ID,PIT_CLIENT_SECRET, andPIT_REDIRECT_URI: your client's values.PIT_CODE: the code that your callback received.PIT_REFRESH_TOKEN: therefresh_tokenreturned by the token exchange.CALENDARPIT_ACCESS_TOKEN: theaccess_tokenreturned 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): Includeclient_idandclient_secretin the URL-encoded body. - HTTP Basic (
client_secret_basic): SendAuthorization: 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
| Item | Lifetime | Single use | Persisted in SQLite across restart |
|---|---|---|---|
| Pending consent nonce | 5 minutes | Yes | No |
| Authorization code | 60 seconds | Yes | No |
| Access token | 1 hour (expires_in: 3600) | No | Yes (with original expiry) |
| Refresh token | No expiry until revoked, user deleted, reset, or fixture load | No | Yes |
| Connection (grant) | Until revoked, user deleted, reset, or fixture load | No | Yes |
- At most 1,000 consents can be pending. Above that, the response is
429 rateLimitExceeded. - An authorization code is bound to its
client_idandredirect_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
scopein the refresh request, it must equal the original space-joined scope string exactly. Otherwise the call fails with400 invalid_scope.
Revocation
To revoke a token, call POST /oauth/revoke:
- Pass an access token or refresh token in the
tokenform 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
200with an empty body. - A missing or blank
tokenreturns400 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/resetorPOST /__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
| Scope | Allowed operations |
|---|---|
https://www.googleapis.com/auth/calendar | All supported Calendar API reads and writes (calendarList.*, calendars.get, events.*, channels.stop, freebusy.query) |
https://www.googleapis.com/auth/calendar.readonly | calendarList.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.events | events.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.readonly | events.list, events.get, events.instances, events.watch, channels.stop, freebusy.query |
https://www.googleapis.com/auth/calendar.calendarlist | calendarList.list, calendarList.get, calendarList.insert, calendarList.patch, calendarList.update, calendarList.delete, calendarList.watch, channels.stop |
https://www.googleapis.com/auth/calendar.calendarlist.readonly | calendarList.list, calendarList.get, calendarList.watch, channels.stop |
https://www.googleapis.com/auth/calendar.calendars.readonly | calendars.get |
https://www.googleapis.com/auth/calendar.freebusy | freebusy.query |
https://www.googleapis.com/auth/calendar.events.freebusy | freebusy.query |
Profile scopes
These scopes allow GET /userinfo. The response always contains { sub, id }, both set to the test user ID.
| Scope | GET /userinfo response |
|---|---|
openid | { sub, id } |
email, https://www.googleapis.com/auth/userinfo.email | Adds email, verified_email: true, and email_verified: true |
profile, https://www.googleapis.com/auth/userinfo.profile | Adds name |
openid only grants access to GET /userinfo with { sub, id }. Calendarpit does not:
- return an
id_tokenfrom/oauth/token - serve
/.well-known/openid-configurationor JWKS - include
picture,given_name,family_name, orlocalein/userinfo
Token and scope errors
| Condition | Response |
|---|---|
| Missing, expired, or revoked access token | 401 (authError) with a WWW-Authenticate: Bearer header |
| Valid token missing a required scope for the endpoint | 403 (insufficientPermissions) |
channels.stop with a token from a different connection | 404 (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'sredirectUrisby exact string equality. - Unknown authorize parameters:
400 unsupported_feature. Examples arenonce,login_hint,hd,request,request_uri, andenable_granular_consent. - Non-interactive consent:
400 unsupported_feature. This coversprompt=noneand anypromptvalue 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
plainmethod:400 invalid_request. - Unlisted scopes:
400 invalid_scope. Examples arecalendar.events.owned,calendar.app.created,calendar.settings.readonly,calendar.acls, and the write scopecalendar.calendars.
Other OAuth limits:
- Other token grant types:
POST /oauth/tokenreturns400 unsupported_grant_typeafter 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.