-
-
Notifications
You must be signed in to change notification settings - Fork 277
feat: added authenticated user storage #8260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c10189d
16163c3
38b17aa
2af31ef
782b5ce
27911b3
91701fc
eedd6cc
ce5671d
cccf519
e907cac
f79f384
55f7144
235b0e5
0f2599a
b7918e5
72e9c2a
acf46c4
3dbc9af
d534083
f2dd2d3
803f104
d415546
00707c6
3800ca8
4d0d255
c70f646
d81b087
d319cb3
35abfa2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to this project will be documented in this file. | ||
|
|
||
| The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), | ||
| and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||
|
|
||
| ## [Unreleased] | ||
|
|
||
| ### Added | ||
|
|
||
| - Initial release ([#8260](https://github.com/MetaMask/core/pull/8260)) | ||
| - `AuthenticatedUserStorage` class with namespaced domain accessors: `delegations` (list, create, revoke) and `preferences` (getNotifications, putNotifications) | ||
|
|
||
| [Unreleased]: https://github.com/MetaMask/core/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| MIT License | ||
|
|
||
| Copyright (c) 2026 MetaMask | ||
|
|
||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
|
|
||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
|
|
||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| # `@metamask/authenticated-user-storage` | ||
|
|
||
| A TypeScript SDK for MetaMask's Authenticated User Storage API. Unlike E2EE user-storage, authenticated user storage holds **structured JSON** scoped to the authenticated user. The server can read and validate the contents, which allows other backend services to consume the data (e.g. delegation execution, notification delivery). | ||
|
|
||
| The SDK currently supports two domains: | ||
|
|
||
| - **Delegations** -- immutable, EIP-712 signed delegation records (list, create, revoke). | ||
| - **Notification Preferences** -- mutable per-user notification settings (get, put). | ||
|
|
||
| ## Installation | ||
|
|
||
| `yarn add @metamask/authenticated-user-storage` | ||
|
|
||
| or | ||
|
|
||
| `npm install @metamask/authenticated-user-storage` | ||
|
|
||
| ## Usage | ||
|
|
||
| ### Creating a client | ||
|
|
||
| The constructor requires two options: | ||
|
|
||
| - **`env`** -- selects the backend environment (`DEV`, `UAT`, or `PRD`). | ||
| - **`getAccessToken`** -- an async callback that returns a valid JWT access token for the current user. In MetaMask clients this is wired through the messenger to `AuthenticationController:getBearerToken`, which handles the full SRP-based OIDC login flow internally. | ||
|
|
||
| ```typescript | ||
| import { | ||
| AuthenticatedUserStorage, | ||
| Env, | ||
| } from '@metamask/authenticated-user-storage'; | ||
|
|
||
| // Inside a controller that has access to the messenger: | ||
| const storage = new AuthenticatedUserStorage({ | ||
| env: Env.PRD, | ||
| getAccessToken: () => | ||
| this.messenger.call('AuthenticationController:getBearerToken'), | ||
| }); | ||
| ``` | ||
|
|
||
| The `env` option selects the backend environment: | ||
|
|
||
| | `Env` value | Server | | ||
| | ----------- | ------------------------------------- | | ||
| | `Env.DEV` | `user-storage.dev-api.cx.metamask.io` | | ||
| | `Env.UAT` | `user-storage.uat-api.cx.metamask.io` | | ||
| | `Env.PRD` | `user-storage.api.cx.metamask.io` | | ||
|
|
||
| The `AuthenticationController` manages the full authentication lifecycle (SRP key derivation, nonce signing, backend authentication, OIDC token exchange, and session caching). Callers do not need to handle tokens directly -- the `getBearerToken` action returns a cached access token or transparently re-authenticates when the session has expired. | ||
|
|
||
| ### Delegations | ||
|
|
||
| Delegations are immutable once stored. They can only be revoked (deleted), not updated. | ||
|
|
||
| ```typescript | ||
| import type { Hex, DelegationSubmission } from '@metamask/authenticated-user-storage'; | ||
|
|
||
| // List all delegations for the authenticated user | ||
| const delegations = await storage.delegations.list(); | ||
|
|
||
| // Submit a new signed delegation | ||
| const submission: DelegationSubmission = { | ||
| signedDelegation: { ... }, | ||
| metadata: { ... }, | ||
| }; | ||
| await storage.delegations.create(submission, 'extension'); | ||
|
|
||
| // Revoke a delegation by its hash | ||
| await storage.delegations.revoke('0xdae6d1...'); | ||
| ``` | ||
|
|
||
| ### Notification preferences | ||
|
|
||
| Preferences are mutable. The first call creates the record; subsequent calls update it. | ||
|
|
||
| ```typescript | ||
| import type { NotificationPreferences, Hex } from '@metamask/authenticated-user-storage'; | ||
|
|
||
| // Retrieve current preferences (returns null if none have been set) | ||
| const prefs = await storage.preferences.getNotifications(); | ||
|
|
||
| // Create or update preferences | ||
| const updated: NotificationPreferences = { | ||
| walletActivity: { ... }, | ||
| marketing: { ... }, | ||
| perps: { ... }, | ||
| socialAI: { ... }, | ||
| }; | ||
| await storage.preferences.putNotifications(updated, 'extension'); | ||
| ``` | ||
|
|
||
| ## Response validation | ||
|
|
||
| All API responses are validated at runtime using [`@metamask/superstruct`](https://github.com/MetaMask/superstruct) schemas before being returned to callers. If the server returns data that doesn't match the expected shape, the SDK throws an `AuthenticatedUserStorageError` with details about the structural mismatch rather than silently returning malformed data. | ||
|
|
||
| ## Error handling | ||
|
|
||
| All methods throw `AuthenticatedUserStorageError` on failure. This covers HTTP errors, response validation failures, and network issues. The error message includes the HTTP status code and the server's error response when available. | ||
|
|
||
| ```typescript | ||
| import { AuthenticatedUserStorageError } from '@metamask/authenticated-user-storage'; | ||
|
|
||
| try { | ||
| await storage.delegations.create(submission); | ||
| } catch (error) { | ||
| if (error instanceof AuthenticatedUserStorageError) { | ||
| console.error(error.message); | ||
| // e.g. "failed to create delegation. HTTP 409 message: delegation already exists, error: Conflict" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Contributing | ||
|
|
||
| This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| /* | ||
| * For a detailed explanation regarding each configuration property and type check, visit: | ||
| * https://jestjs.io/docs/configuration | ||
| */ | ||
|
|
||
| const merge = require('deepmerge'); | ||
| const path = require('path'); | ||
|
|
||
| const baseConfig = require('../../jest.config.packages'); | ||
|
|
||
| const displayName = path.basename(__dirname); | ||
|
|
||
| module.exports = merge(baseConfig, { | ||
| // The display name when running multiple projects | ||
| displayName, | ||
|
|
||
| // An object that configures minimum threshold enforcement for coverage results | ||
| coverageThreshold: { | ||
| global: { | ||
| branches: 50, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason why only 50% of this library is being tested? We highly encourage 100% test coverage because if the coverage tool fails in the future it's going to make it very difficult to find and understand why. |
||
| functions: 50, | ||
| lines: 50, | ||
| statements: 50, | ||
| }, | ||
| }, | ||
|
|
||
| coveragePathIgnorePatterns: [ | ||
| ...baseConfig.coveragePathIgnorePatterns, | ||
| '/__fixtures__/', | ||
| '/mocks/', | ||
| 'index.ts', | ||
| ], | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| { | ||
| "name": "@metamask/authenticated-user-storage", | ||
| "version": "0.0.0", | ||
| "description": "SDK for authenticated (non-encrypted) user storage endpoints", | ||
| "keywords": [ | ||
| "MetaMask", | ||
| "Ethereum" | ||
| ], | ||
| "homepage": "https://github.com/MetaMask/core/tree/main/packages/authenticated-user-storage#readme", | ||
| "bugs": { | ||
| "url": "https://github.com/MetaMask/core/issues" | ||
| }, | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/MetaMask/core.git" | ||
| }, | ||
| "license": "MIT", | ||
| "sideEffects": false, | ||
| "exports": { | ||
| ".": { | ||
| "import": { | ||
| "types": "./dist/index.d.mts", | ||
| "default": "./dist/index.mjs" | ||
| }, | ||
| "require": { | ||
| "types": "./dist/index.d.cts", | ||
| "default": "./dist/index.cjs" | ||
| } | ||
| }, | ||
| "./package.json": "./package.json" | ||
| }, | ||
| "main": "./dist/index.cjs", | ||
| "types": "./dist/index.d.cts", | ||
| "files": [ | ||
| "dist/" | ||
| ], | ||
| "scripts": { | ||
| "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", | ||
| "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", | ||
| "build:docs": "typedoc", | ||
| "changelog:update": "../../scripts/update-changelog.sh @metamask/authenticated-user-storage", | ||
| "changelog:validate": "../../scripts/validate-changelog.sh @metamask/authenticated-user-storage", | ||
| "since-latest-release": "../../scripts/since-latest-release.sh", | ||
| "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", | ||
| "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", | ||
| "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", | ||
| "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" | ||
| }, | ||
| "dependencies": { | ||
| "@metamask/superstruct": "^3.1.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@metamask/auto-changelog": "^3.4.4", | ||
| "@ts-bridge/cli": "^0.6.4", | ||
| "@types/jest": "^29.5.14", | ||
| "deepmerge": "^4.2.2", | ||
| "jest": "^29.7.0", | ||
| "nock": "^13.3.1", | ||
| "ts-jest": "^29.2.5", | ||
| "typedoc": "^0.25.13", | ||
| "typedoc-plugin-missing-exports": "^2.0.0", | ||
| "typescript": "~5.3.3" | ||
| }, | ||
| "engines": { | ||
| "node": "^18.18 || >=20" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "registry": "https://registry.npmjs.org/" | ||
| } | ||
| } |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about moving this from Also, is there a reason why |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import nock from 'nock'; | ||
|
|
||
| import { | ||
| MOCK_DELEGATIONS_URL, | ||
| MOCK_DELEGATION_RESPONSE, | ||
| MOCK_NOTIFICATION_PREFERENCES, | ||
| MOCK_NOTIFICATION_PREFERENCES_URL, | ||
| } from '../mocks/authenticated-userstorage'; | ||
|
|
||
| type MockReply = { | ||
| status: nock.StatusCode; | ||
| body?: nock.Body; | ||
| }; | ||
|
|
||
| export function handleMockListDelegations(mockReply?: MockReply): nock.Scope { | ||
| const reply = mockReply ?? { | ||
| status: 200, | ||
| body: [MOCK_DELEGATION_RESPONSE], | ||
| }; | ||
| return nock(MOCK_DELEGATIONS_URL) | ||
| .persist() | ||
| .get('') | ||
| .reply(reply.status, reply.body); | ||
| } | ||
|
|
||
| export function handleMockCreateDelegation( | ||
| mockReply?: MockReply, | ||
| callback?: (uri: string, requestBody: nock.Body) => Promise<void>, | ||
| ): nock.Scope { | ||
| const reply = mockReply ?? { status: 200 }; | ||
| const interceptor = nock(MOCK_DELEGATIONS_URL).persist().post(''); | ||
|
|
||
| if (callback) { | ||
| return interceptor.reply(reply.status, async (uri, requestBody) => { | ||
| await callback(uri, requestBody); | ||
| }); | ||
| } | ||
| return interceptor.reply(reply.status, reply.body); | ||
| } | ||
dovydas55 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export function handleMockRevokeDelegation(mockReply?: MockReply): nock.Scope { | ||
| const reply = mockReply ?? { status: 204 }; | ||
| return nock(MOCK_DELEGATIONS_URL) | ||
| .persist() | ||
| .delete(/.*/u) | ||
| .reply(reply.status, reply.body); | ||
| } | ||
|
|
||
| export function handleMockGetNotificationPreferences( | ||
| mockReply?: MockReply, | ||
| ): nock.Scope { | ||
| const reply = mockReply ?? { | ||
| status: 200, | ||
| body: MOCK_NOTIFICATION_PREFERENCES, | ||
| }; | ||
| return nock(MOCK_NOTIFICATION_PREFERENCES_URL) | ||
| .persist() | ||
| .get('') | ||
| .reply(reply.status, reply.body); | ||
| } | ||
|
|
||
| export function handleMockPutNotificationPreferences( | ||
| mockReply?: MockReply, | ||
| callback?: (uri: string, requestBody: nock.Body) => Promise<void>, | ||
| ): nock.Scope { | ||
| const reply = mockReply ?? { status: 200 }; | ||
| const interceptor = nock(MOCK_NOTIFICATION_PREFERENCES_URL).persist().put(''); | ||
|
|
||
| if (callback) { | ||
| return interceptor.reply(reply.status, async (uri, requestBody) => { | ||
| await callback(uri, requestBody); | ||
| }); | ||
| } | ||
| return interceptor.reply(reply.status, reply.body); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.