Skip to content
Open
2 changes: 2 additions & 0 deletions packages/api-auth-entity-storage-models/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
export * from "./models/api/IAdminUserCreateRequest.js";
export * from "./models/api/IAdminUserGetByIdentityRequest.js";
Expand All @@ -17,5 +18,6 @@ export * from "./models/api/IUpdatePasswordRequest.js";
export * from "./models/IAuthenticationAdminComponent.js";
export * from "./models/IAuthenticationComponent.js";
export * from "./models/IAuthenticationUser.js";
export * from "./models/IAuthenticationUserCreate.js";
export * from "./models/IAuthenticationUserSecure.js";
export * from "./models/IPasswordOptions.js";
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { IComponent } from "@3sixty/core";
import type { IAuthenticationUser } from "./IAuthenticationUser.js";
import type { IAuthenticationUserCreate } from "./IAuthenticationUserCreate.js";

/**
* Contract definition for authentication admin component.
Expand All @@ -12,7 +14,7 @@ export interface IAuthenticationAdminComponent extends IComponent {
* @param user The user to create.
* @returns A promise that resolves when the user account has been created.
*/
create(user: IAuthenticationUser & { password: string }): Promise<void>;
create(user: IAuthenticationUserCreate): Promise<void>;

/**
* Update a login for the user.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { IComponent } from "@3sixty/core";
import type { IPasswordOptions } from "./IPasswordOptions.js";
Expand All @@ -21,6 +22,12 @@ export interface IAuthenticationComponent extends IComponent {
expiry: number;
}>;

/**
* Attach a DID to the signed-in account. The token subject stays the account id.
* @returns A promise that resolves when the DID has been stored.
*/
adoptIdentity(): Promise<void>;

/**
* Logout the current user.
* @param token The token to logout, if it uses a mechanism with public access.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.

/**
Expand All @@ -11,9 +12,14 @@ export interface IAuthenticationUser {
email: string;

/**
* The user identity.
* Stable identifier of the account. This is the token subject.
*/
userIdentity: string;
userId: string;

/**
* The DID attached to the account, once one has been adopted.
*/
userIdentity?: string;

/**
* The users organization.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.

/**
* Fields accepted when an admin creates a user.
* The DID and the organisation may be omitted. The service allocates the account id and fills the organisation.
*/
export interface IAuthenticationUserCreate {
/**
* The user e-mail address.
*/
email: string;

/**
* Password for the new account.
*/
password: string;

/**
* DID to store on the account. When omitted, the account is created without one.
*/
userIdentity?: string;

/**
* Organisation identity. When omitted, the service uses the organisation on the request context.
*/
organizationIdentity?: string;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2026 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { IAuthenticationUser } from "../IAuthenticationUser.js";
import type { IAuthenticationUserCreate } from "../IAuthenticationUserCreate.js";

/**
* Create a new user as an admin.
Expand All @@ -9,5 +10,5 @@ export interface IAdminUserCreateRequest {
/**
* The body of the request.
*/
body: IAuthenticationUser & { password: string };
body: IAuthenticationUserCreate;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type {
IAdminUserCreateRequest,
Expand All @@ -9,7 +10,8 @@ import type {
IAdminUserUpdatePasswordRequest,
IAdminUserUpdateRequest,
IAuthenticationAdminComponent,
IAuthenticationUser
IAuthenticationUser,
IAuthenticationUserCreate
} from "@3sixty/api-auth-entity-storage-models";
import { BaseRestClient } from "@3sixty/api-core";
import type { IBaseRestClientConfig, INoContentResponse } from "@3sixty/api-models";
Expand Down Expand Up @@ -50,7 +52,7 @@ export class EntityStorageAuthenticationAdminRestClient
* @param user The user to create.
* @returns Nothing.
*/
public async create(user: IAuthenticationUser & { password: string }): Promise<void> {
public async create(user: IAuthenticationUserCreate): Promise<void> {
Guards.object(EntityStorageAuthenticationAdminRestClient.CLASS_NAME, nameof(user), user);
Guards.stringValue(
EntityStorageAuthenticationAdminRestClient.CLASS_NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type {
IAuthenticationComponent,
Expand Down Expand Up @@ -96,6 +97,14 @@ export class EntityStorageAuthenticationRestClient
};
}

/**
* Attach a DID to the signed-in account. The current session is left in place.
* @returns A promise that resolves when the request has completed.
*/
public async adoptIdentity(): Promise<void> {
await this.fetch<INoContentRequest, INoContentResponse>("/identity", "POST");
}

/**
* Logout the current user.
* @param token The token to logout, if it uses a mechanism with public access.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2026 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import { GuardError } from "@3sixty/core";
import { HttpMethod } from "@3sixty/web";
Expand Down Expand Up @@ -71,6 +72,20 @@ describe("EntityStorageAuthenticationAdminRestClient", () => {
expect(JSON.parse(options.body)).toEqual(createUser);
});

test("sends a create body without a DID when userIdentity is omitted", async () => {
fetchMock.mockResolvedValueOnce(noContentResponse());

const { userIdentity, ...withoutDid } = TEST_USER;
await client.create({ ...withoutDid, password: "MyPassword123!" });

const [, options] = fetchMock.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.email).toBe(TEST_USER.email);
expect(body.password).toBe("MyPassword123!");
expect(body.userIdentity).toBeUndefined();
expect(userIdentity).toBe(TEST_USER.userIdentity);
});

test("resolves without a return value", async () => {
fetchMock.mockResolvedValueOnce(noContentResponse());
await expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2026 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import { GuardError } from "@3sixty/core";
import { HttpMethod } from "@3sixty/web";
Expand Down Expand Up @@ -154,6 +155,25 @@ describe("EntityStorageAuthenticationRestClient", () => {
});
});

describe("adoptIdentity", () => {
test("sends POST to /identity with no body", async () => {
fetchMock.mockResolvedValueOnce(noContentResponse());

await client.adoptIdentity();

const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe(`${ENDPOINT}/${PREFIX}/identity`);
expect(options.method).toBe(HttpMethod.POST);
expect(options.body).toBeUndefined();
});

test("resolves without a return value", async () => {
fetchMock.mockResolvedValueOnce(noContentResponse());

await expect(client.adoptIdentity()).resolves.toBeUndefined();
});
});

describe("updatePassword", () => {
test("throws when currentPassword is empty", async () => {
await expect(client.updatePassword("", "MyNewPassword123!")).rejects.toMatchObject({
Expand Down
4 changes: 4 additions & 0 deletions packages/api-auth-entity-storage-service/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
"entityStorageAuthenticationService": {
"loginFailed": "Login failed",
"userNotFound": "The user with the specified e-mail could not be found",
"identityAlreadyAssigned": "The user already has a DID",
"identityComponentMissing": "The identity component is not available",
"identityCreateFailed": "Creating the DID failed",
"passwordMismatch": "The password does not match the user's password",
"nodeIdNotSet": "The node ID has not been set"
},
"entityStorageAuthenticationAdminService": {
"userExists": "The user with the specified e-mail already exists",
"identityInUse": "An account already uses that identity",
"createUserFailed": "Creating the user failed",
"getUserFailed": "Getting the user failed",
"updateUserFailed": "Updating the user failed",
Expand Down
1 change: 1 addition & 0 deletions packages/api-auth-entity-storage-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@3sixty/crypto": "next",
"@3sixty/entity": "next",
"@3sixty/entity-storage-models": "next",
"@3sixty/identity-models": "next",
"@3sixty/nameof": "next",
"@3sixty/vault-models": "next",
"@3sixty/web": "next"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import { entity, property } from "@3sixty/entity";

/**
* Class defining the storage for user login credentials.
*/
@entity({ version: 1 })
@entity({ version: 2 })
export class AuthenticationUser {
/**
* The user e-mail address.
Expand All @@ -26,10 +27,16 @@ export class AuthenticationUser {
public salt!: string;

/**
* The user identity.
* Stable identifier of the account. This is the token subject.
*/
@property({ type: "string", maxLength: 255, isSecondary: true })
public identity!: string;
public userId!: string;

/**
* The DID attached to the account, once one has been adopted.
*/
@property({ type: "string", maxLength: 255, isSecondary: true, optional: true })
public identity?: string;

/**
* The users organization.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2026 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import { entity, property } from "@3sixty/entity";

/**
* Account storage after authorization removed the stored scope, and before each account had its own id.
*/
@entity({ version: 1 })
export class AuthenticationUserV1 {
/**
* The user e-mail address.
*/
@property({ type: "string", format: "email", isPrimary: true })
public email!: string;

/**
* The encrypted password for the user.
*/
@property({ type: "string" })
public password!: string;

/**
* The salt for the password.
*/
@property({ type: "string" })
public salt!: string;

/**
* The user identity.
*/
@property({ type: "string", maxLength: 255, isSecondary: true })
public identity!: string;

/**
* The users organization.
*/
@property({ type: "string", maxLength: 255 })
public organization!: string;

/**
* The password version counter, incremented on every password change to invalidate existing tokens.
*/
@property({ type: "integer", optional: true })
public passwordVersion?: number;
}
2 changes: 2 additions & 0 deletions packages/api-auth-entity-storage-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
export * from "./entities/authenticationUser.js";
export * from "./entities/authenticationUserV0.js";
export * from "./entities/authenticationUserV1.js";
export * from "./models/IAuthHeaderProcessorConfig.js";
export * from "./models/IAuthHeaderProcessorConstructorOptions.js";
export * from "./models/IAuthTokenContext.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2026 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.

/**
Expand All @@ -22,10 +23,15 @@ export interface IAuthTokenContext {
tenantPublicOrigin?: string;

/**
* The identity of the verified user.
* The account id of the verified user. This is the token subject.
*/
userIdentity?: string;

/**
* The stored identity of the verified account, when it has one.
*/
identity?: string;

/**
* The organization of the verified user.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { IAuthenticationRateActionConfig } from "@3sixty/api-auth-models";

Expand Down Expand Up @@ -47,4 +48,10 @@ export interface IEntityStorageAuthenticationServiceConfig {
* @default { maxAttempts: 30, windowMinutes: 60 }
*/
tokenRefreshRateLimit?: IAuthenticationRateActionConfig;

/**
* Optional override for identity adoption rate limit.
* @default { maxAttempts: 5, windowMinutes: 15 }
*/
identityAdoptRateLimit?: IAuthenticationRateActionConfig;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { IEntityStorageAuthenticationServiceConfig } from "./IEntityStorageAuthenticationServiceConfig.js";

Expand Down Expand Up @@ -36,6 +37,12 @@ export interface IEntityStorageAuthenticationServiceConstructorOptions {
*/
tenantAdminComponentType?: string;

/**
* The identity component used to create a DID.
* @default identity-service
*/
identityComponentType?: string;

/**
* The configuration for the authentication.
*/
Expand Down
Loading
Loading