Build a Tape Integration with OAuth
This guide is for integrations and partners registered by Tape: a product with a backend that connects each of its users to their own Tape account — a "Sign in with Tape" button, a sync, or any integration that keeps working while the user is away. It builds on the authorization flow and covers what such an integration has to get right.
If you are calling the API directly from your own code, including on a user's behalf, you do not need any of this — use a personal access token.
1. Request a client
Ask Tape for a confidential client with exact https redirect URIs. Mention whether your app should keep one connection per Tape user. With that setting, a new sign-in replaces that Tape user's previous connection to your app instead of adding a second one — also when the new sign-in comes from a different account in your product, so two of your accounts linked to the same Tape user cannot both stay connected.
A confidential client registered by Tape has no yearly reconnect: its connections last as long as you refresh them at least once every 90 days.
2. Start the sign-in
Start every sign-in from an endpoint on your backend:
- Create a fresh
stateand PKCEcode_verifier. Use PKCE even though your client has a secret. - Store both in a short-lived,
HttpOnly,Securecookie, set on the user's browser by this endpoint. - Redirect the browser to the authorization endpoint, requesting every scope your integration needs.
Tape shows the consent screen on every sign-in, including when the user already approved your app.
Bind the sign-in to the browser, not to state
On the callback, read state and the verifier from the cookie, and compare. Never look up a pending sign-in by the state value alone: an attacker could then start a sign-in in their own browser and have it completed in a victim's browser, linking the victim's Tape account to the attacker's account in your product — and giving the attacker access to the victim's Tape data.
3. Handle the callback
- Check
stateagainst the cookie, andissagainsthttps://api.tapeapp.com. - Exchange the code once. Never retry a code exchange: a code presented twice disconnects the connection it created.
- Key the account on
tape_user_idfrom the token response, and storetape_organization_idbeside it. A person who belongs to two Tape organizations is two Tape users, with two IDs. - Read
scopefrom the token response to learn what was granted. - Overwrite the stored token pair on every sign-in. If your client keeps one connection per user, the new sign-in has just replaced the old connection, and the old tokens stop working.
GET /v1/user with the new access token returns the user's name, email and organization name, if you need them.
4. Keep the connection alive
- Refresh when the access token expires, or when the API answers
401. A confidential client keeps the refresh token from the code exchange for the whole connection: refresh responses contain no new refresh token, so do not overwrite the stored one with an empty value. - Refresh at least once every 90 days, or the connection expires.
- On
invalid_grant, re-read before giving up. Another sign-in may have replaced the stored tokens while your refresh was in flight. Re-read the stored pair and retry once if it changed. Only when the current pair is refused, mark the account as disconnected and ask the user to sign in again.
5. Let the user disconnect
When the user disconnects Tape in your product, revoke the refresh token. That ends the connection in Tape as well, rather than leaving it to expire.
Libraries
Tape is an OAuth 2.1 authorization server, not an OpenID Connect provider: it publishes an OAuth metadata document but no OpenID configuration, and issues no ID token. Configure libraries for plain OAuth.
Auth.js (NextAuth)
{
id: 'tape',
name: 'Tape',
type: 'oauth',
issuer: 'https://api.tapeapp.com',
clientId: process.env.TAPE_CLIENT_ID,
clientSecret: process.env.TAPE_CLIENT_SECRET,
authorization: {
url: 'https://api.tapeapp.com/oauth/authorize',
params: { scope: 'apps:read records:read records:edit' },
},
token: 'https://api.tapeapp.com/oauth/token',
userinfo: 'https://api.tapeapp.com/v1/user',
checks: ['pkce', 'state'],
profile(profile) {
return { id: String(profile.user_id), name: profile.name, email: profile.email };
},
}
- Set
issuer. Tape sendsisson the callback, and without a configured issuer the check fails. - Store the tokens on every sign-in. Auth.js does not update a stored account when the same user signs in again. Write the new token pair in the
events.signIncallback. - NextAuth v4 with the Prisma adapter writes every field of the token response to the account: add
tape_user_id Int?andtape_organization_id Int?to theAccountmodel, or the first sign-in fails. - Auth.js v5 stores only the standard token fields. To keep the Tape IDs, add the same two columns and return them from the provider's
account(tokens)callback, together with the standard fields (access_token,refresh_token,expires_at,scope,token_type), since that callback replaces the default one. - For a public client, set
client: { token_endpoint_auth_method: 'none' }.
openid-client
Discover the server as OAuth rather than OpenID Connect:
import * as client from 'openid-client';
const config = await client.discovery(
new URL('https://api.tapeapp.com'),
process.env.TAPE_CLIENT_ID,
process.env.TAPE_CLIENT_SECRET,
undefined,
{ algorithm: 'oauth2' },
);
requests-oauthlib (Python)
import base64, hashlib, os, secrets
from requests_oauthlib import OAuth2Session
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
def tape_session(state=None):
return OAuth2Session(
client_id,
redirect_uri='https://app.example.com/oauth/callback',
scope=['apps:read', 'records:read', 'records:edit'],
state=state,
)
# Start the sign-in
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()
url, state = tape_session().authorization_url(
'https://api.tapeapp.com/oauth/authorize',
code_challenge=challenge,
code_challenge_method='S256',
)
# Store state and verifier in the cookie, then redirect the browser to url.
# On the callback, after checking state and iss:
token = tape_session(state=state_from_cookie).fetch_token(
'https://api.tapeapp.com/oauth/token',
authorization_response=callback_url,
code_verifier=verifier_from_cookie,
client_secret=client_secret,
)
- Set
OAUTHLIB_RELAX_TOKEN_SCOPE. requests-oauthlib raises an error when the granted scopes differ from the requested ones — for example when Tape ignores a requestedoffline_access— and it checks again on every refresh. - Create the PKCE verifier yourself. The session's
pkce=option keeps the verifier only on the session object that built the authorization URL, so a callback handled in a separate request cannot use it. - Authenticate on refresh.
refresh_token()sends no client credentials by default: passauth=(client_id, client_secret)for a confidential client, orclient_id=client_idfor a public one — or set them once inauto_refresh_kwargs. - For a public client, pass
include_client_id=Truetofetch_token.
Spring Security
When a refresh fails with invalid_grant, Spring Security's default failure handler removes the stored authorized client — which may be the newer one another sign-in just stored. Call OAuth2AuthorizedClientManager.authorize() without a pre-loaded authorized client, so it reads the current tokens before giving up.