Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c10189d
feat: added authenticated user storage
dovydas55 Mar 20, 2026
16163c3
feat: updated changelog
dovydas55 Mar 20, 2026
38b17aa
feat: updated changelog
dovydas55 Mar 20, 2026
2af31ef
feat: fixing linting errors
dovydas55 Mar 20, 2026
782b5ce
feat: fixing linting errors
dovydas55 Mar 20, 2026
27911b3
Merge branch 'main' into MCA-83
dovydas55 Mar 20, 2026
91701fc
feat: updated API contract
dovydas55 Mar 20, 2026
eedd6cc
Merge branch 'MCA-83' of github.com:MetaMask/core into MCA-83
dovydas55 Mar 20, 2026
ce5671d
feat: cleaning up
dovydas55 Mar 23, 2026
cccf519
Merge branch 'main' of github.com:MetaMask/core into MCA-83
dovydas55 Mar 24, 2026
e907cac
feat: added new package authenticated user storage
dovydas55 Mar 24, 2026
f79f384
feat: added missing ASU types
dovydas55 Mar 24, 2026
55f7144
feat: added auth-engineers team
dovydas55 Mar 24, 2026
235b0e5
feat: fixing tests
dovydas55 Mar 24, 2026
0f2599a
feat: fixing tests
dovydas55 Mar 24, 2026
b7918e5
feat: fixing tests
dovydas55 Mar 24, 2026
72e9c2a
feat: fixing tests
dovydas55 Mar 24, 2026
acf46c4
Merge branch 'main' of github.com:MetaMask/core into MCA-83
dovydas55 Mar 24, 2026
3dbc9af
feat: fixing tests
dovydas55 Mar 24, 2026
d534083
Merge branch 'main' of github.com:MetaMask/core into MCA-83
dovydas55 Mar 26, 2026
f2dd2d3
feat: added simple README
dovydas55 Mar 26, 2026
803f104
feat: added simple README
dovydas55 Mar 26, 2026
d415546
feat: added simple README
dovydas55 Mar 26, 2026
00707c6
feat: fixing changelog
dovydas55 Mar 26, 2026
3800ca8
feat: fixing linter
dovydas55 Mar 26, 2026
4d0d255
feat: fixing linter
dovydas55 Mar 26, 2026
c70f646
feat: fixing linter
dovydas55 Mar 26, 2026
d81b087
Merge branch 'main' into MCA-83
dovydas55 Mar 27, 2026
d319cb3
feat: updating docs
dovydas55 Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
/packages/account-tree-controller @MetaMask/accounts-engineers
/packages/profile-sync-controller @MetaMask/accounts-engineers

## Auth Team
/packages/authenticated-user-storage @MetaMask/auth-engineers

## Assets Team
/packages/assets-controllers @MetaMask/metamask-assets
/packages/network-enablement-controller @MetaMask/metamask-assets
Expand Down Expand Up @@ -178,6 +181,8 @@
/packages/phishing-controller/CHANGELOG.md @MetaMask/product-safety @MetaMask/core-platform
/packages/ramps-controller/package.json @MetaMask/ramp @MetaMask/core-platform
/packages/ramps-controller/CHANGELOG.md @MetaMask/ramp @MetaMask/core-platform
/packages/authenticated-user-storage/package.json @MetaMask/auth-engineers @MetaMask/core-platform
/packages/authenticated-user-storage/CHANGELOG.md @MetaMask/auth-engineers @MetaMask/core-platform
/packages/profile-sync-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform
/packages/profile-sync-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform
/packages/selected-network-controller/package.json @MetaMask/wallet-integrations @MetaMask/core-platform
Expand Down
15 changes: 15 additions & 0 deletions packages/authenticated-user-storage/CHANGELOG.md
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/
20 changes: 20 additions & 0 deletions packages/authenticated-user-storage/LICENSE
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
115 changes: 115 additions & 0 deletions packages/authenticated-user-storage/README.md
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).
33 changes: 33 additions & 0 deletions packages/authenticated-user-storage/jest.config.js
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,
functions: 50,
lines: 50,
statements: 50,
},
},

coveragePathIgnorePatterns: [
...baseConfig.coveragePathIgnorePatterns,
'/__fixtures__/',
'/mocks/',
'index.ts',
],
});
71 changes: 71 additions & 0 deletions packages/authenticated-user-storage/package.json
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/"
}
}
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);
}

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);
}
Loading
Loading