Repository: authts/oidc-client-ts (ts)

Issue:
PoC support for Resource Owner Password Credential Flow
Hello,

I need support for Resource Owner Password Credential Flow (https://www.rfc-editor.org/rfc/rfc6749#section-4.3). `oidc-client-ts` does not support it... but the changes needed to support it are very small.

I have made it in my OIDC playground, but I have needed to duplicate some classes in my project because they are @internal and cannot be used/extended in client projects. It would be much easier if this support was directly provided by oidc-client-ts. Watch my PoC at https://gitlab.com/javier-sedano/oidc-vue/-/tree/master/src/oidc-client-ts and deployed to "production" at https://jsedano.duckdns.org/oidc-vue/ .

My questions:

- Are you guys open to receiving such a contribution?
- How? Fork + PR?

BTW, I know that Resource Owner Password Credential Flow is only secure if the Client Application is fully trusted not to keep the credentials... but in scenarios where the IdP and the Client Application are owned by the same entity (which is my case) it is acceptable (for example, read https://www.rfc-editor.org/rfc/rfc6749#section-1.3.3 or  https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow). So I would add the appropriate warning to the readme.



Pull request:
Add Resource Owner Password Credentials flow
Closes #746 and #665

I have done my best to mimic the patterns and name conventions in the existing code base, but comments are welcome.

### Checklist

- [X] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [X] I have included links for closing relevant issue numbers


Patch:
diff --git a/README.md b/README.md
index 21089651..c1db3e7d 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Implements the following OAuth 2.0 protocols and supports
 
 - [Authorization Code Grant](https://oauth.net/2/grant-types/authorization-code/)
   with [PKCE](https://oauth.net/2/pkce/)
+- [Resource Owner Password Credentials Grant](https://www.rfc-editor.org/rfc/rfc6749#section-1.3.3); however, read the [security concerns](docs/ropc.md) before using this flow
 - [Refresh Token Grant](https://oauth.net/2/grant-types/refresh-token/)
 
 ## Table of Contents
@@ -82,3 +83,4 @@ We appreciate feedback and contribution to this repo!
 This project is licensed under the Apache-2.0 license. See the
 [LICENSE](https://github.com/authts/oidc-client-ts/blob/main/LICENSE) file for
 more info.
+
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index 52e73428..e650c94f 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -298,6 +298,8 @@ export class OidcClient {
     // (undocumented)
     readonly metadataService: MetadataService;
     // (undocumented)
+    processResourceOwnerPasswordCredentials({ username, password, skipUserInfo, }: ProcessResourceOwnerPasswordCredentialsArgs): Promise<SigninResponse>;
+    // (undocumented)
     processSigninResponse(url: string): Promise<SigninResponse>;
     // (undocumented)
     processSignoutResponse(url: string): Promise<SignoutResponse>;
@@ -553,6 +555,13 @@ export interface PopupWindowParams {
     popupWindowTarget?: string;
 }
 
+// @public (undocumented)
+export type ProcessResourceOwnerPasswordCredentialsArgs = {
+    username: string;
+    password: string;
+    skipUserInfo?: boolean;
+};
+
 // @public (undocumented)
 export type QuerySessionStatusArgs = IFrameWindowParams & ExtraSigninRequestArgs;
 
@@ -662,6 +671,9 @@ export interface SigninRequestArgs {
     url: string;
 }
 
+// @public (undocumented)
+export type SigninResourceOwnerCredentialsArgs = ProcessResourceOwnerPasswordCredentialsArgs;
+
 // @public (undocumented)
 export class SigninResponse {
     constructor(params: URLSearchParams);
@@ -874,6 +886,8 @@ export type UserLoadedCallback = (user: User) => Promise<void> | void;
 // @public
 export class UserManager {
     constructor(settings: UserManagerSettings);
+    // (undocumented)
+    protected _buildUser(signinResponse: SigninResponse, verifySub?: string): Promise<User>;
     clearStaleState(): Promise<void>;
     // (undocumented)
     protected readonly _client: OidcClient;
@@ -919,6 +933,7 @@ export class UserManager {
     signinPopupCallback(url?: string, keepOpen?: boolean): Promise<void>;
     signinRedirect(args?: SigninRedirectArgs): Promise<void>;
     signinRedirectCallback(url?: string): Promise<User>;
+    signinResourceOwnerCredentials({ username, password, skipUserInfo, }: SigninResourceOwnerCredentialsArgs): Promise<User>;
     signinSilent(args?: SigninSilentArgs): Promise<User | null>;
     signinSilentCallback(url?: string): Promise<void>;
     // Warning: (ae-forgotten-export) The symbol "NavigateResponse" needs to be exported by the entry point index.d.ts
diff --git a/docs/ropc.md b/docs/ropc.md
new file mode 100644
index 00000000..5f5314f5
--- /dev/null
+++ b/docs/ropc.md
@@ -0,0 +1,13 @@
+## Security concerns on Resource Owner Password Credentials flow
+
+This OAuth 2.0 flow implies some security risks, so you should only use it after a security assesment.
+
+To start with, this flow is not part of the [Open ID Connect standard](https://openid.net/specs/openid-connect-core-1_0.html). Furthermore, although it is part of [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749#section-4.3), it has been removed in the [OAuth 2.1 draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#section-10), and there are good reasons for this:
+
+* When using this flow, the credentials of the user are exposed to the client application. The RFC mandates that ["the client application MUST discard the credentials once the access token has been obtained"](https://www.rfc-editor.org/rfc/rfc6749#section-4.3.1), but there is no technical way for the Identity Provider / Authorization Server to enforce this point. This flow MUST NOT be allowed unless the Client can be enforced to fulfill this requirement through other means (probably because both are under the same security domain, probably the same organization or similar constraints). This point is covered [several](https://www.rfc-editor.org/rfc/rfc6749#section-1.3.3) [times](https://www.rfc-editor.org/rfc/rfc6749#section-4.3) during the RFC and by some IdP implementations, such as [Auth0](https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow) or [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_resource_owner_password_credentials_flow), but it is sometimes quickly ignored by some developers.
+
+* Even if the previous point is covered, this flow is increasing the attack surface of the system. For example, if the Client Application is compromised (maybe through an XSS attack, for example), the credentials of the user are exposed, so the attacker have access to other applications accessible by the user. In comparison, for example, in the Authorization Code Flow if the Client Application is equaly compromised only the access token is exposed, usually meaning that only the given application has been compromised. A different way to see this is: usually the IdP/Authorization Server are strongly protected, and the Client Applications are allowed lower levels of security auditing... but when using this flow, if any of them is exposed, the user credentials are exposed; so both of them should be equaly treated regarding security.
+
+Therefor, this flow MUST NOT be used as a replacement for the Authorization Code flow. This flow can only be seen as a replacement of classic form-based user/password authentication directly in the application.
+
+Then, why are we adding this support in `oidc-client-ts`? Well... form-based user/password authentication is actually widely used in the industry, and using a standard IdP as authenticator for this architecture has some benefits (other things, such as password expiration, user management backoffice, etc are provided for free by the IdP). So this flow can be an easy help in this scenario. But you MUST NOT use this flow believing that you are having all the security benefits of OpenId Connect or OAuth; you are not.
diff --git a/package-lock.json b/package-lock.json
index 5274d111..560697d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1199,9 +1199,9 @@
       }
     },
     "node_modules/@sinclair/typebox": {
-      "version": "0.24.46",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.46.tgz",
-      "integrity": "sha512-ng4ut1z2MCBhK/NwDVwIQp3pAUOCs/KNaW3cBxdFB2xTDrOuo1xuNmpr/9HHFhxqIvHrs1NTH3KJg6q+JSy1Kw==",
+      "version": "0.24.51",
+      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
+      "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==",
       "dev": true
     },
     "node_modules/@sinonjs/commons": {
@@ -4526,23 +4526,23 @@
       }
     },
     "node_modules/jest-mock": {
-      "version": "29.2.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.0.tgz",
-      "integrity": "sha512-aiWGR0P8ivssIO17xkehLGFtCcef2ZwQFNPwEer1jQLHxPctDlIg3Hs6QMq1KpPz5dkCcgM7mwGif4a9IPznlg==",
+      "version": "29.2.2",
+      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.2.tgz",
+      "integrity": "sha512-1leySQxNAnivvbcx0sCB37itu8f4OX2S/+gxLAV4Z62shT4r4dTG9tACDywUAEZoLSr36aYUTsVp3WKwWt4PMQ==",
       "dev": true,
       "dependencies": {
-        "@jest/types": "^29.2.0",
+        "@jest/types": "^29.2.1",
         "@types/node": "*",
-        "jest-util": "^29.2.0"
+        "jest-util": "^29.2.1"
       },
       "engines": {
         "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
       }
     },
     "node_modules/jest-mock/node_modules/@jest/types": {
-      "version": "29.2.0",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.0.tgz",
-      "integrity": "sha512-mfgpQz4Z2xGo37m6KD8xEpKelaVzvYVRijmLPePn9pxgaPEtX+SqIyPNzzoeCPXKYbB4L/wYSgXDL8o3Gop78Q==",
+      "version": "29.2.1",
+      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.1.tgz",
+      "integrity": "sha512-O/QNDQODLnINEPAI0cl9U6zUIDXEWXt6IC1o2N2QENuos7hlGUIthlKyV4p6ki3TvXFX071blj8HUhgLGquPjw==",
       "dev": true,
       "dependencies": {
         "@jest/schemas": "^29.0.0",
@@ -4566,12 +4566,12 @@
       }
     },
     "node_modules/jest-mock/node_modules/jest-util": {
-      "version": "29.2.0",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.0.tgz",
-      "integrity": "sha512-8M1dx12ujkBbnhwytrezWY0Ut79hbflwodE+qZKjxSRz5qt4xDp6dQQJaOCFvCmE0QJqp9KyEK33lpPNjnhevw==",
+      "version": "29.2.1",
+      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.1.tgz",
+      "integrity": "sha512-P5VWDj25r7kj7kl4pN2rG/RN2c1TLfYYYZYULnS/35nFDjBai+hBeo3MDrYZS7p6IoY3YHZnt2vq4L6mKnLk0g==",
       "dev": true,
       "dependencies": {
-        "@jest/types": "^29.2.0",
+        "@jest/types": "^29.2.1",
         "@types/node": "*",
         "chalk": "^4.0.0",
         "ci-info": "^3.2.0",
@@ -7880,9 +7880,9 @@
       }
     },
     "@sinclair/typebox": {
-      "version": "0.24.46",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.46.tgz",
-      "integrity": "sha512-ng4ut1z2MCBhK/NwDVwIQp3pAUOCs/KNaW3cBxdFB2xTDrOuo1xuNmpr/9HHFhxqIvHrs1NTH3KJg6q+JSy1Kw==",
+      "version": "0.24.51",
+      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
+      "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==",
       "dev": true
     },
     "@sinonjs/commons": {
@@ -10260,20 +10260,20 @@
       }
     },
     "jest-mock": {
-      "version": "29.2.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.0.tgz",
-      "integrity": "sha512-aiWGR0P8ivssIO17xkehLGFtCcef2ZwQFNPwEer1jQLHxPctDlIg3Hs6QMq1KpPz5dkCcgM7mwGif4a9IPznlg==",
+      "version": "29.2.2",
+      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.2.tgz",
+      "integrity": "sha512-1leySQxNAnivvbcx0sCB37itu8f4OX2S/+gxLAV4Z62shT4r4dTG9tACDywUAEZoLSr36aYUTsVp3WKwWt4PMQ==",
       "dev": true,
       "requires": {
-        "@jest/types": "^29.2.0",
+        "@jest/types": "^29.2.1",
         "@types/node": "*",
-        "jest-util": "^29.2.0"
+        "jest-util": "^29.2.1"
       },
       "dependencies": {
         "@jest/types": {
-          "version": "29.2.0",
-          "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.0.tgz",
-          "integrity": "sha512-mfgpQz4Z2xGo37m6KD8xEpKelaVzvYVRijmLPePn9pxgaPEtX+SqIyPNzzoeCPXKYbB4L/wYSgXDL8o3Gop78Q==",
+          "version": "29.2.1",
+          "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.1.tgz",
+          "integrity": "sha512-O/QNDQODLnINEPAI0cl9U6zUIDXEWXt6IC1o2N2QENuos7hlGUIthlKyV4p6ki3TvXFX071blj8HUhgLGquPjw==",
           "dev": true,
           "requires": {
             "@jest/schemas": "^29.0.0",
@@ -10294,12 +10294,12 @@
           }
         },
         "jest-util": {
-          "version": "29.2.0",
-          "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.0.tgz",
-          "integrity": "sha512-8M1dx12ujkBbnhwytrezWY0Ut79hbflwodE+qZKjxSRz5qt4xDp6dQQJaOCFvCmE0QJqp9KyEK33lpPNjnhevw==",
+          "version": "29.2.1",
+          "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.1.tgz",
+          "integrity": "sha512-P5VWDj25r7kj7kl4pN2rG/RN2c1TLfYYYZYULnS/35nFDjBai+hBeo3MDrYZS7p6IoY3YHZnt2vq4L6mKnLk0g==",
           "dev": true,
           "requires": {
-            "@jest/types": "^29.2.0",
+            "@jest/types": "^29.2.1",
             "@types/node": "*",
             "chalk": "^4.0.0",
             "ci-info": "^3.2.0",
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index f367f585..897e22bd 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -58,6 +58,15 @@ export interface UseRefreshTokenArgs {
  */
 export type CreateSignoutRequestArgs = Omit<SignoutRequestArgs, "url" | "state_data"> & { state?: unknown };
 
+/**
+ * @public
+ */
+export type ProcessResourceOwnerPasswordCredentialsArgs = {
+    username: string;
+    password: string;
+    skipUserInfo?: boolean;
+};
+
 /**
  * Provides the raw OIDC/OAuth2 protocol support for the authorization endpoint and the end session endpoint in the
  * authorization server. It provides a bare-bones protocol implementation and is used by the UserManager class.
@@ -165,6 +174,18 @@ export class OidcClient {
         return response;
     }
 
+    public async processResourceOwnerPasswordCredentials({
+        username,
+        password,
+        skipUserInfo = false,
+    }: ProcessResourceOwnerPasswordCredentialsArgs): Promise<SigninResponse> {
+        const tokenResponse: Record<string, unknown> = await this._tokenClient.exchangeCredentials({ username, password });
+        const signinResponse: SigninResponse = new SigninResponse(new URLSearchParams());
+        Object.assign(signinResponse, tokenResponse);
+        await this._validator.validateCredentialsResponse(signinResponse, skipUserInfo);
+        return signinResponse;
+    }
+
     public async useRefreshToken({
         state,
         timeoutInSeconds,
diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts
index 4bdd244e..ffc4e29b 100644
--- a/src/ResponseValidator.ts
+++ b/src/ResponseValidator.ts
@@ -71,6 +71,18 @@ export class ResponseValidator {
         logger.debug("claims processed");
     }
 
+    public async validateCredentialsResponse(response: SigninResponse, skipUserInfo: boolean): Promise<void> {
+        const logger = this._logger.create("validateCredentialsResponse");
+
+        if (response.isOpenId) {
+            this._validateIdTokenAttributes(response);
+        }
+        logger.debug("tokens validated");
+
+        await this._processClaims(response, skipUserInfo, response.isOpenId);
+        logger.debug("claims processed");
+    }
+
     public async validateRefreshResponse(response: SigninResponse, state: RefreshState): Promise<void> {
         const logger = this._logger.create("validateRefreshResponse");
 
diff --git a/src/TokenClient.ts b/src/TokenClient.ts
index c4a805e8..74549548 100644
--- a/src/TokenClient.ts
+++ b/src/TokenClient.ts
@@ -19,6 +19,20 @@ export interface ExchangeCodeArgs {
     code_verifier?: string;
 }
 
+/**
+ * @internal
+ */
+export interface ExchangeCredentialsArgs {
+    client_id?: string;
+    client_secret?: string;
+
+    grant_type?: string;
+    scope?: string;
+
+    username: string;
+    password: string;
+}
+
 /**
  * @internal
  */
@@ -109,6 +123,48 @@ export class TokenClient {
         return response;
     }
 
+    public async exchangeCredentials({
+        grant_type = "password",
+        client_id = this._settings.client_id,
+        client_secret = this._settings.client_secret,
+        scope = this._settings.scope,
+        username,
+        password,
+    }: ExchangeCredentialsArgs): Promise<Record<string, unknown>> {
+        const logger = this._logger.create("exchangeCredentials");
+
+        if (!client_id) {
+            logger.throw(new Error("A client_id is required"));
+        }
+
+        const params = new URLSearchParams({ grant_type, username, password, scope });
+
+        let basicAuth: string | undefined;
+        switch (this._settings.client_authentication) {
+            case "client_secret_basic":
+                if (!client_secret) {
+                    logger.throw(new Error("A client_secret is required"));
+                    throw null; // https://github.com/microsoft/TypeScript/issues/46972
+                }
+                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret);
+                break;
+            case "client_secret_post":
+                params.append("client_id", client_id);
+                if (client_secret) {
+                    params.append("client_secret", client_secret);
+                }
+                break;
+        }
+
+        const url = await this._metadataService.getTokenEndpoint(false);
+        logger.debug("got token endpoint");
+
+        const response = await this._jsonService.postForm(url, { body: params, basicAuth });
+        logger.debug("got response");
+
+        return response;
+    }
+
     public async exchangeRefreshToken({
         grant_type = "refresh_token",
         client_id = this._settings.client_id,
diff --git a/src/UserManager.ts b/src/UserManager.ts
index 3bb83e1d..dd5f56ed 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -5,7 +5,7 @@ import { Logger } from "./utils";
 import { ErrorResponse } from "./errors";
 import { IFrameNavigator, NavigateResponse, PopupNavigator, RedirectNavigator, PopupWindowParams,
     IWindow, IFrameWindowParams, RedirectParams } from "./navigators";
-import { OidcClient, CreateSigninRequestArgs, CreateSignoutRequestArgs } from "./OidcClient";
+import { OidcClient, CreateSigninRequestArgs, CreateSignoutRequestArgs, ProcessResourceOwnerPasswordCredentialsArgs } from "./OidcClient";
 import { UserManagerSettings, UserManagerSettingsStore } from "./UserManagerSettings";
 import { User } from "./User";
 import { UserManagerEvents } from "./UserManagerEvents";
@@ -15,6 +15,7 @@ import type { SessionStatus } from "./SessionStatus";
 import type { SignoutResponse } from "./SignoutResponse";
 import type { MetadataService } from "./MetadataService";
 import { RefreshState } from "./RefreshState";
+import type { SigninResponse } from "./SigninResponse";
 
 /**
  * @public
@@ -45,6 +46,11 @@ export type SigninPopupArgs = PopupWindowParams & ExtraSigninRequestArgs;
  */
 export type SigninSilentArgs = IFrameWindowParams & ExtraSigninRequestArgs;
 
+/**
+ * @public
+ */
+export type SigninResourceOwnerCredentialsArgs = ProcessResourceOwnerPasswordCredentialsArgs;
+
 /**
  * @public
  */
@@ -171,6 +177,30 @@ export class UserManager {
         return user;
     }
 
+    /**
+     * Returns promise to process the signin with user/password. The result of the promise is the authenticated `User`.
+     *
+     * Throws an ErrorResponse in case of wrong authentication.
+     */
+    public async signinResourceOwnerCredentials({
+        username,
+        password,
+        skipUserInfo = false,
+    }: SigninResourceOwnerCredentialsArgs ) {
+        const logger = this._logger.create("signinResourceOwnerCredential");
+
+        const signinResponse = await this._client.processResourceOwnerPasswordCredentials({ username, password, skipUserInfo });
+        logger.debug("got signin response");
+
+        const user = await this._buildUser(signinResponse);
+        if (user.profile && user.profile.sub) {
+            logger.info("success, signed in subject", user.profile.sub);
+        } else {
+            logger.info("no subject");
+        }
+        return user;
+    }
+
     /**
      * Returns promise to trigger a request (via a popup window) to the authorization endpoint. The result of the promise is the authenticated `User`.
      */
@@ -404,6 +434,12 @@ export class UserManager {
         const signinResponse = await this._client.processSigninResponse(url);
         logger.debug("got signin response");
 
+        const user = await this._buildUser(signinResponse, verifySub);
+        return user;
+    }
+
+    protected async _buildUser(signinResponse: SigninResponse, verifySub?: string) {
+        const logger = this._logger.create("_buildUser");
         const user = new User(signinResponse);
         if (verifySub) {
             if (verifySub !== user.profile.sub) {


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index e5aac357..d2798b05 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -335,6 +335,54 @@ describe("OidcClient", () => {
         });
     });
 
+    describe("processResourceOwnerPasswordCredentials", () => {
+
+        it("should fail on wrong credentials", async () => {
+            // arrange
+            jest.spyOn(subject["_tokenClient"], "exchangeCredentials").mockRejectedValue(new Error("Wrong credentials"));
+
+            // act
+            await expect(subject.processResourceOwnerPasswordCredentials({ username: "u", password: "p", skipUserInfo: false }))
+                // assert
+                .rejects.toThrow(Error);
+        });
+
+        it("should fail on invalid response", async () => {
+            // arrange
+            jest.spyOn(subject["_tokenClient"], "exchangeCredentials").mockResolvedValue({});
+            jest.spyOn(subject["_validator"], "validateCredentialsResponse").mockRejectedValue(new Error("Wrong response"));
+
+            // act
+            await expect(subject.processResourceOwnerPasswordCredentials({ username: "u", password: "p", skipUserInfo: false }))
+                // assert
+                .rejects.toThrow(Error);
+        });
+
+        it("should return response on success", async () => {
+            // arrange
+            jest.spyOn(subject["_tokenClient"], "exchangeCredentials").mockResolvedValue({
+                access_token: "access_token",
+                id_token: "id_token",
+                scope: "openid profile email",
+            });
+            jest.spyOn(subject["_validator"], "validateCredentialsResponse").mockImplementation(
+                async (response: SigninResponse): Promise<void> => {
+                    Object.assign(response, { profile: { sub: "subsub" } });
+                },
+            );
+
+            // act
+            const signinResponse: SigninResponse = await subject.processResourceOwnerPasswordCredentials({ username: "u", password: "p", skipUserInfo: false });
+
+            // assert
+            expect(signinResponse).toHaveProperty("access_token", "access_token");
+            expect(signinResponse).toHaveProperty("id_token", "id_token");
+            expect(signinResponse).toHaveProperty("scope", "openid profile email");
+            expect(signinResponse).toHaveProperty("profile", { sub: "subsub" });
+        });
+
+    });
+
     describe("useRefreshToken", () => {
         it("should return a SigninResponse", async () => {
             // arrange
diff --git a/src/ResponseValidator.test.ts b/src/ResponseValidator.test.ts
index ff85b288..54133cc0 100644
--- a/src/ResponseValidator.test.ts
+++ b/src/ResponseValidator.test.ts
@@ -483,6 +483,124 @@ describe("ResponseValidator", () => {
         });
     });
 
+    describe("validateCredentialsResponse", () => {
+
+        it("should process a valid openid signin response (skipping userInfo)", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: true });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, true);
+
+            // assert
+            expect(JwtUtils.decode).toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).not.toHaveBeenCalledWith();
+            expect(stubResponse).toHaveProperty("profile", { sub: "subsub" });
+        });
+
+        it("should not process an invalid openid signin response", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: true });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: undefined });
+
+            // act
+            await expect(subject.validateCredentialsResponse(stubResponse, true))
+                // assert
+                .rejects.toThrow(Error);
+            expect(JwtUtils.decode).toHaveBeenCalledWith("id_token");
+            expect(stubResponse).not.toHaveProperty("profile");
+        });
+
+        it("should process a valid non-openid signin response skipping userInfo", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: false });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, true);
+
+            // assert
+            expect(JwtUtils.decode).not.toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).not.toHaveBeenCalledWith();
+            expect(stubResponse).toHaveProperty("profile", { });
+        });
+
+        it("should process a valid non-openid signin response (not loading userInfo)", async () => {
+            // arrange
+            Object.assign(settings, { loadUserInfo: false });
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: false });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, false);
+
+            // assert
+            expect(JwtUtils.decode).not.toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).not.toHaveBeenCalledWith();
+            expect(stubResponse).toHaveProperty("profile", { });
+        });
+
+        it("should process a valid non-openid signin response without access_token", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: false, access_token: "" });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, false);
+
+            // assert
+            expect(JwtUtils.decode).not.toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).not.toHaveBeenCalledWith();
+            expect(stubResponse).toHaveProperty("profile", { });
+        });
+
+        it("should process a valid non-openid signin response with userInfo", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: false, access_token: "access_token" });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, false);
+
+            // assert
+            expect(JwtUtils.decode).not.toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).toHaveBeenCalledWith("access_token");
+            expect(stubResponse).toHaveProperty("profile", { nickname: "Nick" });
+        });
+
+        it("should not process a valid openid signin response with wrong userInfo", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: true, access_token: "access_token" });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+            jest.spyOn(subject["_userInfoService"], "getClaims").mockResolvedValue({ sub: "anotherSub", nickname: "Nick" });
+
+            // act
+            await expect(subject.validateCredentialsResponse(stubResponse, false))
+                // assert
+                .rejects.toThrow(Error);
+            expect(JwtUtils.decode).toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).toHaveBeenCalledWith("access_token");
+            expect(stubResponse).toHaveProperty("profile", { sub: "subsub" });
+        });
+
+        it("should process a valid openid signin response with correct userInfo", async () => {
+            // arrange
+            Object.assign(stubResponse, { id_token: "id_token", isOpenId: true, access_token: "access_token" });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+            jest.spyOn(subject["_userInfoService"], "getClaims").mockResolvedValue({ sub: "subsub", nickname: "Nick" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, false);
+
+            // assert
+            expect(JwtUtils.decode).toHaveBeenCalledWith("id_token");
+            expect(subject["_userInfoService"].getClaims).toHaveBeenCalledWith("access_token");
+            expect(stubResponse).toHaveProperty("profile", { sub: "subsub", nickname: "Nick" });
+        });
+
+    });
+
     describe("_mergeClaims", () => {
         it("should merge claims", () => {
             // arrange
diff --git a/src/TokenClient.test.ts b/src/TokenClient.test.ts
index c344ac5f..c205da1a 100644
--- a/src/TokenClient.test.ts
+++ b/src/TokenClient.test.ts
@@ -144,6 +144,106 @@ describe("TokenClient", () => {
         });
     });
 
+    describe("exchangeCredentials", () => {
+
+        it("should have client_id", async () => {
+            // arrange
+            settings.client_id = "";
+            subject = new TokenClient(new OidcClientSettingsStore(settings), metadataService);
+
+            // act
+            await expect(subject.exchangeCredentials({ username: "u", password: "p" }))
+                // assert
+                .rejects.toThrow(Error);
+        });
+
+        it("should have client_secret when using client_secret_basic", async () => {
+            // arrange
+            delete settings.client_secret;
+            settings.client_authentication = "client_secret_basic";
+            subject = new TokenClient(new OidcClientSettingsStore(settings), metadataService);
+
+            // act
+            await expect(subject.exchangeCredentials({ username: "u", password: "p" }))
+                // assert
+                .rejects.toThrow(Error);
+        });
+
+        it("should calculate basic auth when using client_secret_basic", async () => {
+            // arrange
+            settings.client_authentication = "client_secret_basic";
+            settings.client_secret = "client_secret";
+            subject = new TokenClient(new OidcClientSettingsStore(settings), metadataService);
+            const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
+                .mockResolvedValue("http://sts/token_endpoint");
+            const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
+                .mockResolvedValue({});
+            const generateBasicAuthSpy = jest.spyOn(CryptoUtils, "generateBasicAuth");
+
+            // act
+            await subject.exchangeCredentials({ username: "u", password: "p" });
+
+            // assert
+            expect(generateBasicAuthSpy).toHaveBeenCalledWith("client_id", "client_secret");
+            expect(getTokenEndpointMock).toBeCalledWith(false);
+            expect(postFormMock).toBeCalledWith(
+                "http://sts/token_endpoint",
+                expect.objectContaining({
+                    body: expect.any(URLSearchParams),
+                    basicAuth: expect.stringContaining("Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ="),
+                }),
+            );
+        });
+
+        it("should include client secret when using client_secret_post", async () => {
+            // arrange
+            settings.client_authentication = "client_secret_post";
+            settings.client_secret = "client_secret";
+            subject = new TokenClient(new OidcClientSettingsStore(settings), metadataService);
+            const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
+                .mockResolvedValue("http://sts/token_endpoint");
+            const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
+                .mockResolvedValue({});
+
+            // act
+            await subject.exchangeCredentials({ username: "u", password: "p" });
+
+            // assert
+            expect(getTokenEndpointMock).toBeCalledWith(false);
+            expect(postFormMock).toBeCalledWith(
+                "http://sts/token_endpoint",
+                expect.objectContaining({
+                    body: expect.any(URLSearchParams),
+                    basicAuth: undefined,
+                }),
+            );
+            const opts = postFormMock.mock.calls[0][1];
+            const params = Object.fromEntries(opts.body);
+            expect(params).toHaveProperty("client_secret", "client_secret");
+        });
+
+        it("should call postForm", async () => {
+            // arrange
+            const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
+                .mockResolvedValue("http://sts/token_endpoint");
+            const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
+                .mockResolvedValue({});
+
+            // act
+            await subject.exchangeCredentials({ username: "u", password: "p" });
+
+            // assert
+            expect(getTokenEndpointMock).toBeCalledWith(false);
+            expect(postFormMock).toBeCalledWith(
+                "http://sts/token_endpoint",
+                expect.objectContaining({
+                    body: expect.any(URLSearchParams),
+                    basicAuth: undefined,
+                }),
+            );
+        });
+    });
+
     describe("exchangeRefreshToken", () => {
         it("should have client_id", async () => {
             // arrange
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 1ef0dc84..049083f5 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -263,6 +263,49 @@ describe("UserManager", () => {
         });
     });
 
+    describe("signinResourceOwnerCredentials", () => {
+        it("should fail on wrong credentials", async () => {
+            // arrange
+            jest.spyOn(subject["_client"], "processResourceOwnerPasswordCredentials").mockRejectedValue(new Error("Wrong credentials"));
+
+            // act
+            await expect(subject.signinResourceOwnerCredentials({ username: "u", password: "p" }))
+                // assert
+                .rejects.toThrow(Error);
+        });
+
+        it("should convert and store received response", async () => {
+            // arrange
+            const mockUser = {
+                access_token: "access_token",
+                token_type: "Bearer",
+                profile: {
+                    sub: "subsub",
+                    iss: "ississ",
+                    aud: "aud",
+                    exp: 0,
+                    iat: 0,
+                    nickname: "Nick",
+                },
+                session_state: "ssss",
+                expires_at: 0,
+                refresh_token: "refresh_token",
+                id_token: "id_token",
+                scope: "openid profile email",
+            };
+            jest.spyOn(subject["_client"], "processResourceOwnerPasswordCredentials").mockResolvedValue(mockUser as SigninResponse);
+            jest.spyOn(subject["_events"], "load").mockReturnValue();
+
+            // act
+            const user:User = await subject.signinResourceOwnerCredentials({ username: "u", password: "p" });
+
+            // assert
+            await expect(subject.getUser()).resolves.toEqual(mockUser);
+            expect(subject["_events"].load).toHaveBeenCalledWith(mockUser);
+            expect(user).toEqual(mockUser);
+        });
+    });
+
     describe("signinPopup", () => {
         it("should pass navigator params to navigator", async () => {
             // arrange