0.5.1 • Published 6 months ago

@andyrmitchell/authension v0.5.1

Weekly downloads
-
License
-
Repository
-
Last release
6 months ago

TokenVault

The OAuth library for signing in and managing tokens locally inside a browser extension

Highlights

  • Secure local management of OAuth Access (or Refresh) Tokens, taking advantage of secure browser extension APIs
  • Multiple authorisation flow methods, with per-user switching to accomodate different security environments and UX needs
  • Sign into multiple accounts within one browser profile
  • Incremental scope granting, to only request what is needed
  • Anonymised logging to track per-user problems

Highlights coming soon

  • React components for the most common workflows
  • Silently sign into Supabase with your tokens

Security at a glance

Data cannot be intercepted

  • Tokens are encrypted on disk, so naive token-sniffing malware cannot read them
  • Tokens are only held in memory for the duration of their immediate use, reducing the chance of interception
  • It uses the browser's extension-only storage locations, so tokens cannot be accessed by any other websites or extensions
  • Only 1 external dependency - Zod, used by 9 million projects - so unlikely any compromised packages can infiltrate

Oauth flows use best practice

  • OAuth flows are secured by: chrome.identity.launchWebAuthFlow, PKCE, nonces, 'state' checks and secure ID token RS256 authentication
  • The OAuth credentials (e.g. client_id) can be loaded from a remote definition, to enable easy credential rotation, and instant invalidation

Tool support protects against unexpected behaviours

  • Zod provides runtime schema validation at all data boundaries, preventing unexpected errors from malformed data
  • TypeScript is configured with the tightest strictness settings, e.g. noUncheckedIndexedAccess, to protect against malformed data
  • The build process first runs the code against the latest OWASP static security scanner, and cancels the build if there are errors

Authorisation Methods

Recommended best practice:

  • Provide the methods remotely, so you can hot switch them
  • Start with an access token only solution (because storing a refresh token requires exposing the credential's client_secret)
    • Only switch to a local refresh token flow if the browser is requiring reauthentication so often it harms user experience
  • Additionally provide alternative methods that a user can select, if they're having difficulty with a primary solution (e.g. if their organisation has precise security requirements - and they must use getAuthToken or Auth0).

Method Capabilities

NameMethodAccess TokenRefresh TokenSafe client_secretControl reauthentication scheduleMultiple accounts per browser profileGoogle ProviderAuth0 ProviderCross browser
Access Tokens from OIDC Implicit Grant (which is safe) using launchWebAuthFlowlwaf-oidc-implicit-grant-access
Access Tokens from Authorization Code using launchWebAuthFlowlwaf-authorization-code-access
Access Tokens from Secretless Authorization Code using launchWebAuthFlowlwaf-authorization-code-secretless-access
Access Tokens using getAuthTokenauth-token
Refresh Tokens from Authorization Code using launchWebAuthFlowlwaf-authorization-code-refreshbut it's safe

How it works

OAuth Best Practice Guidelines

  • If you use a method that exposes the client_secret (e.g. lwaf-authorization-code-refresh)

    • Do not use that Credential for any other system. Just your extension.
    • Use remote OAuth client fetching, and regularly rotate them (deleting the old credential and creating a new one). If there's been any compromise, the rotation will block the unintended tokens. It will require users to reauthorize, so pick an acceptable schedule (e.g. every 2 months).
  • If your OAuth client provider is google

    • Your extension should use Cross-Account Protection (https://developers.google.com/identity/risc). The extension will act to shut down the client_id if a mass breach is detected. Or if a single user is compromised, the extension must be notified to automtically revoke the token, or if they no longer use the extension there will be a process to email them with instructions to manually revoke the token.

General Best Practice Guidelines

  • If you need to authorize a locally signed in user with your server

    • Send their id_token, not their access_token. The access_token potentially has scoped powers and is a much more dangerous token.
  • Whitelist your 'connect-src' in your browser manifest's Content Security Policy (CSP), to reduce the chance of unintended exfiltration of data

    • Only allow access to your trusted servers (including your OAuth providers).
      • At the time of writing, Google and Auth0 providers can be included with these patterns: connect-src 'self' https://accounts.google.com/o/oauth2/ https://accounts.google.com/.well-known/ https://*.googleapis.com https://*.us.auth0.com

Security Discussion

This package makes some trade offs of best practices, under the philosophy that there's no such thing as perfect security, and the best approach is to protect against common attacks and reduce the overall attack surface of an application.

Why store tokens locally?

The common argument against it is that local storage is less secure than a remote server. However...

Why the file system token encryption uses an exposed key

It's important to understand the only goal of file system encryption is to protect against naive malware that scans hard drives for token-like data.

If a hacker targets your extension, they will eventually get in:

  • No locally stored decryption key can fully protect against such sophisticated threats.
    • Therefore using more hidden decryption keys offers no real security benefit, and only creates a false sense of safety.

But as discussed in why store tokens locally:

  • They require pre-installed malware targeting this module. Something so rare and severe can do much more damage than just tokens.

Roadmap for Enhanced Security

There are meaningful ways to make it more costly for hackers to extract tokens. (Note still nothing can prevent attacks).

  1. Storing the decryption key in a secure cookie, in the extension's background. The browser takes responsibility for encrypting this, adding an extra layer that must be compromised.
  2. Implement a one-time-use per-user decryption key stored on a remote server. See Roadmap.
    • The key is accessed via the Loopback Mechanism, which means the malware would need to also corrupt the browser installation itself. (OSes offer some protection against that).
Further Reading

What is the browser Loopback Mechanism, and why is it so powerful for security?

The browser’s loopback mechanism ensures data can only received by your extension.

  • You request data from a service, providing a redirect URI to send the data too
  • The redirect URI is in the format https://<extension id>.chromiumapp.org/* (obtained by chrome.identity.getRedirectURL()).
  • chromiumapp.org is owned and protected by Google
  • Data sent by the remote service to https://<extension id>.chromiumapp.org/ can only be received by your extension.

By coupling it with an OAuth Credential that has a locked down Allowed Callback URLs - as both our supported providers require (Google and Auth0) - then response data cannot be sent to any other destination.

An attacker can spoof an extension's ID with an Unpacked Extension, but browsers like Chrome prevent that from remaining installed unattended for more than a few days. And an attacker cannot use an Unpacked Extension just on their own device, as OAuth requires the user to enter their password to confirm the login process.

It's officially discouraged as it's generally unsafe (discussed below). But there are two reasons it's safe here.

This package uses OIDC conformance:

  • It provides, and verifies, a nonce to prevent replay attacks
  • It authenticates the secure id_token by ensuring it's signed with RS256 using the provider's known public key and that it matches its iss
  • It uses state to prevent CSRF attacks
  • It verifies the aud claim matches the expected credential's client_id

It remedies the reasons Implicit Grant is discouraged:

  • Token is in URL fragment
    • It uses chrome.identity.launchWebAuthFlow and https, so the token can neither be intercepted by rogue scripts in the same world, nor recorded by browser history.
  • Vulnerable to Open Redirect Attacks and MITM attacks
    • It combines the OAuth Providers Allowed Callback URLs with the browser's Loopback Mechanism, to ensure tokens are only sent to your extension. The returned ID token is then authenticated against the provider's RS256 public key.
  • Vulnerable to CSRF
    • Using state guarantees the response is a result of the original authentication request
  • Vulnerable to replay attacks
    • The use of the nonce and state protect against replay attacks.

Why use refresh tokens locally when doing so requires exposing the client_secret?

The risk is a trade off against user experience

Non-refresh token methods have unpredictable browser login cache durations. Frequently asking users to re-login degrades user experience.

Example: Imagine you're using Gmail, which periodically asks you to log in, but then the Gmail extension also keeps asking you to log in, but on a very different cycle... it's irritating.

Mitigating the risks of client_secret

  • Risk: Unauthorized Token Generation
    • Example:
      • With your client_id and client_secret, an imposter has the raw tools to trick their users into generating tokens. This is especially dangerous if your Credentials have been granted rare permission to offer sensitive scopes (e.g. access to Gmail's email).
    • Mitigation:
      • Extension Authenticity:
        • Browser vendors (e.g., Chrome and the Chrome Web Store) prevent duplicate extension IDs. (Unpacked Extensions can be spoofed to duplicate IDs, but the browser prevents you from running these for long periods).
      • Strict Origin Restrictions:
        • Both the package's supported providers - Google and Auth0 - enforce strict Allowed Origins (CORS) and Allowed Callback URLs. This means only your registered extension or website can complete the OAuth flow.
      • Loopback Mechanism:
        • The browser’s loopback mechanism ensures tokens are only received by your legitimate extension, even if an attacker possesses the client_id and client_secret.
      • Credential Rotation
        • This package's remote credential definitions technique makes it easy to regularly rotate the client_id and client_secret, so any tokens generated with it and revoked.
  • Risk: Authorization Code Interception
    • Example:
      • A MITM or Open Redirect attack could steal the Authorization Code generated, which if they then know the client_secret, then can exchange it for a token.
    • Mitigation:
      • PKCE

        • Using PKCE (Proof Key for Code Exchange) is a solid replacement for the client_secret: it generates a one-time-use code verifier and code challenge, which is dynamic and not susceptible to interception.

Why not use Chrome's getAuthToken as the default option?

  • It works on a model of "1 user per extension and Chrome profile". This is a non-starter for an extension designed to handle multiple users, e.g. a Gmail extension would be used by people who log into muliple Gmail accounts in the same Chrome window.
  • At the time of writing (2024), it's horribly undocumented/unloved by Chrome's dev team. e.g. https://groups.google.com/a/chromium.org/g/chromium-extensions/c/4OX3cv_wepY
  • It's Chrome-specific. Firefox/Safari/etc. also need solutions.
  • Possibly also: restricted to Google as the identity provider (cannot support Auth0?)

Roadmap

Support Supabase sign in in background

Most likely use-case is to get the SupabaseClient to be able to call it (see Store), or to user the JWT in a bearer

See https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=platform&platform=web

### Add OWASP checking during the build process

### For revokeToken to be more reliable, store the method every time the token is stored

Because currently, if a method is dropped from the supported list,

Build React components for Login

With functionality to:

  • Start a branded login
  • Show common errors and how to resolve them
    • Offer other sign in mechanisms
  • Behave differently if it's an incremental update

Potentially it can be part of this as a mono-repo, if it makes testing easier. But probably it should be 2 repos (where one can test they both stay in sync with a testing script that pulls the latest from both and identifies failures trying to build it: i.e. to spot if changing this repo leads to a breaking change in that one).

Add tests for Sessions

Mock up the background messaging (possibly wrap such helpers up with the monkey patches for chrome.identity.*/fetch and the similar mocks used in the 'Store' repo?)

Including:

  • Check incremental scopes work as expected in ClientSession
  • Hammer it with several clients trying to authorize the same email address at once

Encrypt local tokens with a key created per installation, and stored in a secure cookie in the extension background.

Encrypt local tokens with a per-user one-time-use key, that uses CORS and the Loopback Mechanism to only send it to the extension (it can't be retrieved by others). Requires Chrome itself to be corrupted.

  • The server must use the usual fingerprint/suspicious-activity checks against mass exfiltration (e.g. if the user's geography suddenly changes, they must re-sign-in).
  • Potentially the server could use cookies and require a sign in itself (or be a secure 3rd party service that does the same, but not at the cost of losing the Loopback Mechanism). This could be an option for power users to nominate their own, and regularly re-sign in.
  • Potentially create the key remotely and sign it with a server held private key (after verifying the user and gaining an ID token so the server can guarantee they intended to make the key), to verify that the client has not been compromised and is generating its own keys.
  • Problem: Or a thief to use Chromium and an Unpacked Extension that spoofs it.
    • Store the ID token unencrypted (or a key derivation), and make that part of the unencryption process. So now the attacker also needs to use malware to steal from the user's computer.
    • They still have to get the user to go through the approval process. Can't just do it on their machine. So the user must be using corrupted Chrome / false Unpacked Extension.

Remote invalidation for a single user

  • Because tokens are held client side, the server cannot revoke them
  • Make accessing an authorized token require that it checks to see if that user was invalidated, and to remove that token

It was built to secure our 1 extension, which uses Google as the Provider.

  • Auth0 is included as a proof of concept. It has UX issues to solve (like chrome.identity.launchWebAuthFlow informing the user they're logging into 'Auth0' instead of your app's name).
  • It might be worth supporting other popular Providers.

Sign into more services (e.g. Supabase, Firebase, etc) with your single token, so your users only have to visually authenticate once

  • Currently Supabase can be signed into with the id_token you generate. But the technique should work with other services.

Pick things from the Roadmap for the testing/live codebase

## Acknowledgements

### Included dependencies

The source code of these dependencies have been included in this package as vendors (instead of using a registry like npm, which potentially introduces security vulnerabilities later).

  • Jose JWT Signing (License: MIT)

## Misc

Setting up Auth0

Set up:

  • Auth0 Create Auth0 account
  • Auth0 Create a Social Connection (sidebar: Authentication / Social)
    • gmail-oauth2
  • Auth0 Create an Application
    • Type: Single Page Application
    • Connections:
      • Add/enable the gmail-oauth2
    • Settings:
      • Allowed Callback URLs
        • https://.chromiumapp.org/oauth2
      • Allowed Origins (CORS)
        • chrome-extension://
  • GCP Set up Credential in GCP
    • Type: Web Application
    • Authorized redirect URIs
      • https:///login/callback
        • is found in the Sidebar / Applications / Application / / Settings. Looks like: dev-a7wfug8rzk7ecbg2.us.auth0.com
  • Auth0 Add your GCP Client ID / Secret to the Social Connection
    • Go to: Sidebar / Authentication / Social / gmail-oauth2 (as above) / Settings
    • Add 'Client ID' and 'Client Secret'
    • Do not enable any permissions

When using this, the passed in 'client_id' is your Auth0 client ID (not GCP), found in Auth0 / Sidebar / Applications / Application / / Settings

To enable refresh tokens, with a client secret needed in the client:

  • Auth0 go to Sidebar / Applications / Applications / / Credentials
  • Set Application Authentication / Authentication Method to 'None'

Troubleshooting

## TypeScript

Cannot access 'error' (or some other expected property) on .authorize or .getAccessAndIDToken

Authension returns discrimated unions, e.g. {ok: true, tokens: any} | {ok: false, error: any}. If the consumer doesn't have strictNullChecks enabled, then this code will fail:

type Result = {ok: true, tokens: any} | {ok: false, error: any};
const result:Result = await tokenVault.authorize(...);
if( result.ok ) {

} else {
    result.error // Will say 'error' does not exist, because TypeScript can't discriminate the union.
}

To resolve it, either:

  • Enable strictNullChecks in your tsconfig.json (recommended)
  • Use explicit conditional checking. Replace if( result.ok ) with if( result.ok===true ). Typescript will correctly discrimate this.
  • Use the loosenUnionRecordType function from @andyrmitchell/utils, e.g. const result = loosenUnionRecordType(await tokenVault.authorize(...)). Then you can freely address every property.
0.5.1

6 months ago

0.5.0

6 months ago

0.4.8

6 months ago

0.4.7

6 months ago

0.4.6

6 months ago

0.4.5

6 months ago

0.4.4

6 months ago

0.4.3

6 months ago

0.4.2

6 months ago

0.4.1

6 months ago

0.4.0

6 months ago

0.3.3

7 months ago

0.3.2

7 months ago

0.3.1

7 months ago

0.3.0

7 months ago

0.2.2

7 months ago

0.2.1

7 months ago

0.2.0

7 months ago

0.1.5

7 months ago

0.1.4

7 months ago

0.1.3

7 months ago

0.1.2

7 months ago

0.1.1

7 months ago

0.1.0

7 months ago

0.0.6

7 months ago

0.0.5

7 months ago

0.0.4

7 months ago

0.0.3

8 months ago

0.0.2

8 months ago

0.0.1

8 months ago