Skip to content

Authenticating to external services

Extensions that talk to an external service on the user's behalf usually need a credential of some kind. This page describes the tools PopClip provides for signing in to services and storing secrets.

API key authentication

If the service just needs an API key that the user can obtain and paste in, you don't need any special machinery. Define an option of type secret: it appears as a concealed text field, and PopClip stores the value in the user's keychain.

For anything more involved — validating a username and password, or an OAuth sign-in — use the auth function.

The auth function

A module extension can define an auth function. When it does, PopClip shows a Sign in button in the action's settings UI, and calls the function when the user clicks it:

ts
type AuthFunction = (
  info: AuthInfo,
  flow: AuthFlowFunction,
) => Promise<string | AuthResult>;

The info object carries the values of the extension's username and password options (if defined), the extension's name and identifier, and a redirect URL for use in OAuth flows.

Whatever the function returns is saved in the user's keychain as the extension's authsecret, and the settings UI switches to a signed-in state with a Sign out button (which clears the stored secret). Return a plain string, or an AuthResult object { secret, label, expiresIn } — the label is shown as the signed-in account identifier, and expiresIn (a token lifetime in seconds) makes PopClip treat the sign-in as expired after that time.

Username and password sign-in

For services that authenticate with a username and password, define options with the identifiers username and password. PopClip passes their values to the auth function in info. An option of type password is never stored — it exists only to be passed to the auth function.

The Pinboard extension uses this pattern to retrieve the user's API token. Here is a compact but complete version of it, as an installable snippet — the auth function signs in, and the action then uses the stored authsecret to bookmark the selected URL:

javascript
// #popclip
// name: Pinboard
// icon: iconify:simple-icons:pinboard
// requirements: [url]
// entitlements: [network]
// after: show-status
import axios from "axios";
const api = axios.create({
  baseURL: "https://api.pinboard.in/v1/",
  params: { format: "json" },
});
defineExtension({
  options: [
    { identifier: "username", type: "string", label: "Username" },
    { identifier: "password", type: "password", label: "Password" },
  ],
  auth: async (info) => {
    // validate the credentials by fetching the user's API token,
    // using HTTP basic authentication
    const response = await api.get("user/api_token", { auth: info });
    return response.data.result;
  },
  action: async (input, options, context) => {
    // bookmark the selected URL
    const url = input.data.urls[0];
    const description = context.browserUrl === url ? context.browserTitle : url;
    const auth_token = `${options.username}:${options.authsecret}`;
    await api.get("posts/add", { params: { url, description, auth_token } });
  },
});

secret vs password options

Both option types conceal their input; the difference is what happens to the value. A secret option is stored in the user's keychain, for a credential the extension keeps and uses — typically a pasted API key. A password option is never stored: the auth function uses it once to obtain a token from the service, and only the token is kept. PopClip never retains the user's actual password.

OAuth sign-in

For OAuth authorization-code flows, use the flow callback passed as the auth function's second parameter. Calling it opens the service's authorization page in the user's browser, with your parameters appended. After the user approves, the service redirects the browser to the info.redirect URL — a local address that PopClip itself serves — and flow resolves with the query parameters you named in expect:

ts
defineExtension({
  auth: async (info, flow) => {
    // step 1: the user authorizes the extension in their browser
    const { code } = await flow(
      "https://example.com/oauth/authorize",
      { client_id, redirect_uri: info.redirect },
      ["code"],
    );
    // step 2: exchange the authorization code for an access token
    const { data } = await axios.post("https://example.com/oauth/token", {
      grant_type: "authorization_code",
      code,
      client_id,
      client_secret,
      redirect_uri: info.redirect,
    });
    return { secret: data.access_token, expiresIn: data.expires_in };
  },
});

The Raindrop.io extension is a complete working example of this pattern.

For services still using OAuth 1.0a request signing, the oauth-1.0a library is bundled in PopClip's JavaScript environment.

Using the stored secret

Action code reads the stored secret as options.authsecret. It has one special behaviour: accessing it while the extension is not signed in throws an error, so an action that requires sign-in fails with a "Not signed in" message rather than proceeding with an empty credential.

ts
defineExtension({
  action: {
    requirements: ["url"],
    async code(input, options) {
      await axios.post(
        "https://example.com/api/save",
        { url: input.data.urls[0] },
        { headers: { Authorization: `Bearer ${options.authsecret}` } },
      );
      popclip.showSuccess();
    },
  },
});

If the service rejects the stored secret — an expired or revoked token, say — throw the error returned by popclip.signInRequiredError(). PopClip clears the saved secret, so the extension shows as signed out, and opens the settings UI for the user to sign in again. (The related popclip.settingsRequiredError() sends the user to settings without signing them out — for example when a required option is missing.)

Registering as a client app

Before you can use OAuth, you have to register an application with the service to obtain a client identifier. A few notes on doing that as an extension author.

Register in your own name. The registration is yours: you hold the credentials, and the service will contact you about quotas, policy changes and anything it considers abuse. Please don't register as just "PopClip", or use the PopClip icon.

Choose a name that tells the user what they are approving. The name you register appears on the authorization page shown when the user signs in, so it is what they will use to decide whether to trust the request. Identify that it is an extension for PopClip and who made it. Example: "Raindrop Extension for PopClip, by @author"

Link to your own repository. Where the service asks for a homepage or support URL, give the extension's own GitHub repository or web page, not this website.

Register as a native or desktop app. The redirect URL you need is the one supplied as info.redirect, which looks like http://localhost:58906/callback/com.example.popclip.extension.myextension/auth. Some services accept a localhost redirect only for apps registered as native or desktop clients, so choose that type if you are asked.

If your extension is later published in the PopClip Extensions Directory, get in touch and we can revisit the registration then.

Storing the client secret

If your registration gives you a client secret, you have a small problem: there is nowhere to hide it. Client secrets have to ship inside the extension, and an extension is source code that anyone can read.

The util.clarify function is used here. It deciphers a JSON object that has been lightly obscured — stringify, then Base64, then ROT13 — so the credentials at least don't sit in the source as plaintext, where they could be scraped or indexed:

ts
import { credentials } from "./client.json"; // { "credentials": "<obscured string>" }
const { client_id, client_secret } = util.clarify(credentials);

To be clear: this is obfuscation and not security. Anyone determined can recover the values by reversing the process. That is an accepted limitation. Client credentials are embedded in ordinary apps too, and can be extracted from them just the same. Treat an extension's client credentials as protected from casual exposure rather than secret.

To prepare an obscured blob, apply the reverse of clarify to your JSON: encode it as Base64, then apply ROT13 to the result. You can do it right here:

rlWwoTyyoaEsnJDvBvWuLzZkZwZvYPWwoTyyoaEsp2IwpzI0Vwbvp2ubnPW9
  • authServiceLabel — a label for the service, used in prompts such as "Sign in to your [label] account". Defaults to the extension's name.
  • authKeychain — which keychain the sign-in secret goes in: sync (the default) shares one sign-in across the user's devices via iCloud Keychain; local keeps it on the Mac where the user signed in, so each device signs in separately. Declare local where the service issues per-device credentials, such as OAuth flows with rotating refresh tokens or dynamic client registration.