Merge branch 'main' into translation

This commit is contained in:
Max Peintner
2024-12-11 09:36:24 +01:00
committed by GitHub
52 changed files with 1623 additions and 573 deletions

View File

@@ -1,8 +1,39 @@
name: Quality
on: pull_request
on:
pull_request:
schedule:
# Every morning at 6:00 AM CET
- cron: '0 4 * * *'
workflow_dispatch:
inputs:
target-env:
description: 'Zitadel target environment to run the acceptance tests against.'
required: true
type: choice
options:
- 'qa'
- 'prod'
jobs:
matrix:
# If the workflow is triggered by a schedule event, only the acceptance tests run against QA and Prod.
name: Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Matrix
id: matrix
run: |
if [ -n "${{ github.event.schedule }}" ]; then
echo 'matrix=["test:acceptance:qa", "test:acceptance:prod"]' >> $GITHUB_OUTPUT
elif [ -n "${{ github.event.inputs.target-env }}" ]; then
echo 'matrix=["test:acceptance:${{ github.event.inputs.target-env }}"]' >> $GITHUB_OUTPUT
else
echo 'matrix=["format --check", "lint", "test:unit", "test:integration", "test:acceptance"]' >> $GITHUB_OUTPUT
fi
quality:
name: Ensure Quality
@@ -13,15 +44,13 @@ jobs:
permissions:
contents: "read"
needs:
- matrix
strategy:
fail-fast: false
matrix:
command:
- format --check
- lint
- test:unit
- test:integration
- test:acceptance
command: ${{ fromJson( needs.matrix.outputs.matrix ) }}
steps:
- name: Checkout Repo
@@ -55,7 +84,7 @@ jobs:
# We can cache the Playwright binary independently from the pnpm cache, because we install it separately.
# After pnpm install --frozen-lockfile, we can get the version so we only have to download the binary once per version.
- run: echo "PLAYWRIGHT_VERSION=$(npx playwright --version | cut -d ' ' -f 2)" >> $GITHUB_ENV
if: ${{ matrix.command == 'test:acceptance' }}
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- uses: actions/cache@v4.0.2
name: Setup Playwright binary cache
@@ -65,24 +94,33 @@ jobs:
key: ${{ runner.os }}-playwright-binary-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-binary-
if: ${{ matrix.command == 'test:acceptance' }}
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
if: ${{ matrix.command == 'test:acceptance' && steps.playwright-cache.outputs.cache-hit != 'true' }}
if: ${{ startsWith(matrix.command, 'test:acceptance') && steps.playwright-cache.outputs.cache-hit != 'true' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
if: ${{ matrix.command == 'test:acceptance' }}
- name: Run ZITADEL
run: ZITADEL_DEV_UID=root pnpm run-zitadel
run: ZITADEL_DEV_UID=root pnpm run-sink
if: ${{ matrix.command == 'test:acceptance' }}
- name: Create Cloud Env File
run: |
if [ "${{ matrix.command }}" == "test:acceptance:prod" ]; then
echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_PROD }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null
else
echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_QA }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null
fi
if: ${{ matrix.command == 'test:acceptance:qa' || matrix.command == 'test:acceptance:prod' }}
- name: Create Production Build
run: pnpm build
if: ${{ matrix.command == 'test:acceptance' }}
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- name: Check
id: check
run: pnpm ${{ matrix.command }}
run: pnpm ${{ contains(matrix.command, 'test:acceptance') && 'test:acceptance' || matrix.command }}

View File

@@ -230,6 +230,24 @@ pnpm test
To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts.
### Run Login UI Acceptance tests
To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's.
This component should also be able to return the content of these notifications, as the codes and links are used in the login flows.
There is a basic implementation in Golang available under [the sink package](./acceptance/sink).
To setup ZITADEL with the additional Sink container for handling the notifications:
```sh
pnpm run-sink
```
Then you can start the acceptance tests with:
```sh
pnpm test:acceptance
```
### Deploy to Vercel
To deploy your own version on Vercel, navigate to your instance and create a service user.

View File

@@ -1,7 +1,7 @@
services:
zitadel:
user: "${ZITADEL_DEV_UID}"
image: ghcr.io/zitadel/zitadel:v2.65.0
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:v2.65.0}"
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
ports:
- "8080:8080"
@@ -22,7 +22,7 @@ services:
- POSTGRES_HOST_AUTH_METHOD=trust
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
test: [ "CMD-SHELL", "pg_isready" ]
interval: "10s"
timeout: "30s"
retries: 5
@@ -45,6 +45,9 @@ services:
ZITADEL_API_INTERNAL_URL: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local
SINK_EMAIL_INTERNAL_URL: http://sink:3333/email
SINK_SMS_INTERNAL_URL: http://sink:3333/sms
SINK_NOTIFICATION_URL: http://localhost:3333/notification
volumes:
- "./pat:/pat"
- "../apps/login:/apps/login"
@@ -52,3 +55,15 @@ services:
depends_on:
wait_for_zitadel:
condition: "service_completed_successfully"
sink:
image: golang:1.19-alpine
container_name: sink
command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification'
ports:
- 3333:3333
volumes:
- "./sink:/sink"
depends_on:
setup:
condition: "service_completed_successfully"

View File

@@ -8,6 +8,9 @@ ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}"
ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}"
ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}"
ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}"
SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}"
SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}"
SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}"
if [ -z "${PAT}" ]; then
echo "Reading PAT from file ${PAT_FILE}"
@@ -24,6 +27,10 @@ if [ -z "${ZITADEL_SERVICE_USER_ID}" ]; then
ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub')
fi
#################################################################
# Environment files
#################################################################
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local}
@@ -32,6 +39,7 @@ echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done."
echo "ZITADEL_API_URL=${ZITADEL_API_URL}
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
ZITADEL_SERVICE_USER_TOKEN=${PAT}
SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL}
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
cat ${WRITE_ENVIRONMENT_FILE}
@@ -39,6 +47,54 @@ cat ${WRITE_ENVIRONMENT_FILE}
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
cat ${WRITE_TEST_ENVIRONMENT_FILE}
#################################################################
# SMS provider with HTTP
#################################################################
SMSHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}"
SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id')
echo "Received SMS HTTP ID: ${SMSHTTP_ID}"
SMS_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}"
#################################################################
# Email provider with HTTP
#################################################################
EMAILHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}"
EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id')
echo "Received Email HTTP ID: ${EMAILHTTP_ID}"
EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}"
#################################################################
# Wait for projection of default organization in ZITADEL
#################################################################
DEFAULTORG_RESPONSE_RESULTS=0
# waiting for default organization
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
@@ -53,3 +109,4 @@ do
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
done

3
acceptance/sink/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/zitadel/typescript/acceptance/sink
go 1.22.6

104
acceptance/sink/main.go Normal file
View File

@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
)
type serializableData struct {
ContextInfo map[string]interface{} `json:"contextInfo,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
}
type response struct {
Recipient string `json:"recipient,omitempty"`
}
func main() {
port := flag.String("port", "3333", "used port for the sink")
email := flag.String("email", "/email", "path for a sent email")
emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification")
sms := flag.String("sms", "/sms", "path for a sent sms")
smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification")
notification := flag.String("notification", "/notification", "path to receive the notification")
flag.Parse()
messages := make(map[string]serializableData)
http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
email, ok := serializableData.ContextInfo[*emailKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(email + ": " + string(data))
messages[email] = serializableData
io.WriteString(w, "Email!\n")
})
http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
phone, ok := serializableData.ContextInfo[*smsKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(phone + ": " + string(data))
messages[phone] = serializableData
io.WriteString(w, "SMS!\n")
})
http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := response{}
if err := json.Unmarshal(data, &response); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData, err := json.Marshal(messages[response.Recipient])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, string(serializableData))
})
fmt.Println("Starting server on", *port)
fmt.Println(*email, " for email handling")
fmt.Println(*sms, " for sms handling")
fmt.Println(*notification, " for retrieving notifications")
err := http.ListenAndServe(":"+*port, nil)
if err != nil {
panic("Server could not be started: " + err.Error())
}
}

View File

@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";
const codeTextInput = "code-text-input";
export async function codeScreen(page: Page, code: string) {
await page.getByTestId(codeTextInput).pressSequentially(code);
}
export async function codeScreenExpect(page: Page, code: string) {
await expect(page.getByTestId(codeTextInput)).toHaveValue(code);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code");
}

19
acceptance/tests/code.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Page } from "@playwright/test";
import { codeScreen } from "./code-screen";
import { getOtpFromSink } from "./sink";
export async function otpFromSink(page: Page, key: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getOtpFromSink(key);
await code(page, c);
}
export async function code(page: Page, code: string) {
await codeScreen(page, code);
await page.getByTestId("submit-button").click();
}
export async function codeResend(page: Page) {
await page.getByTestId("resend-button").click();
}

View File

@@ -1,6 +1,8 @@
import { expect, Page } from "@playwright/test";
import { code, otpFromSink } from "./code";
import { loginname } from "./loginname";
import { password } from "./password";
import { totp } from "./zitadel";
export async function startLogin(page: Page) {
await page.goto("/loginname");
@@ -23,6 +25,17 @@ export async function loginScreenExpect(page: Page, fullName: string) {
await expect(page.getByRole("heading")).toContainText(fullName);
}
export async function loginWithOTP(page: Page, username: string, password: string) {
export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) {
await loginWithPassword(page, username, password);
await otpFromSink(page, email);
}
export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) {
await loginWithPassword(page, username, password);
await otpFromSink(page, phone);
}
export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) {
await loginWithPassword(page, username, password);
await code(page, totp(secret));
}

View File

@@ -1,12 +1,12 @@
import { expect, Page } from "@playwright/test";
const usernameUserInput = "username-text-input";
const usernameTextInput = "username-text-input";
export async function loginnameScreen(page: Page, username: string) {
await page.getByTestId(usernameUserInput).pressSequentially(username);
await page.getByTestId(usernameTextInput).pressSequentially(username);
}
export async function loginnameScreenExpect(page: Page, username: string) {
await expect(page.getByTestId(usernameUserInput)).toHaveValue(username);
await expect(page.getByTestId(usernameTextInput)).toHaveValue(username);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user");
}

View File

@@ -1,31 +0,0 @@
import * as http from "node:http";
let messages = new Map<string, any>();
export function startSink() {
const hostname = "127.0.0.1";
const port = 3030;
const server = http.createServer((req, res) => {
console.log("Sink received message: ");
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
console.log(body);
const data = JSON.parse(body);
messages.set(data.contextInfo.recipientEmailAddress, data.args.code);
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.write("OK");
res.end();
});
});
server.listen(port, hostname, () => {
console.log(`Sink running at http://${hostname}:${port}/`);
});
return server;
}

View File

@@ -1,5 +1,7 @@
import { expect, Page } from "@playwright/test";
import { getCodeFromSink } from "./sink";
const codeField = "code-text-input";
const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";
const lengthCheck = "length-check";
@@ -55,3 +57,26 @@ async function checkContent(page: Page, testid: string, match: boolean) {
await expect(page.getByTestId(testid)).toContainText(noMatchText);
}
}
export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(username);
await page.getByTestId(codeField).pressSequentially(c);
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}
export async function resetPasswordScreenExpect(
page: Page,
password1: string,
password2: string,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await changePasswordScreenExpect(page, password1, password2, length, symbol, number, uppercase, lowercase, equals);
}

View File

@@ -1,7 +1,8 @@
import { Page } from "@playwright/test";
import { changePasswordScreen, passwordScreen } from "./password-screen";
import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen";
const passwordSubmitButton = "submit-button";
const passwordResetButton = "reset-button";
export async function startChangePassword(page: Page, loginname: string) {
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
@@ -17,3 +18,13 @@ export async function password(page: Page, password: string) {
await passwordScreen(page, password);
await page.getByTestId(passwordSubmitButton).click();
}
export async function startResetPassword(page: Page) {
await page.getByTestId(passwordResetButton).click();
}
export async function resetPassword(page: Page, username: string, password: string) {
await startResetPassword(page);
await resetPasswordScreen(page, username, password, password);
await page.getByTestId(passwordSubmitButton).click();
}

View File

@@ -1,27 +1,39 @@
import { faker } from "@faker-js/faker";
import { test } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect } from "./login";
import { registerWithPasskey, registerWithPassword } from "./register";
import { removeUserByUsername } from "./zitadel";
test("register with password", async ({ page }) => {
const username = "register-password@example.com";
const password = "Password1!";
const firstname = "firstname";
const lastname = "lastname";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
test("register with password", async ({ page }) => {
const username = faker.internet.email();
const password = "Password1!";
const firstname = faker.person.firstName();
const lastname = faker.person.lastName();
await removeUserByUsername(username);
await registerWithPassword(page, firstname, lastname, username, password, password);
await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
});
test("register with passkey", async ({ page }) => {
const username = "register-passkey@example.com";
const firstname = "firstname";
const lastname = "lastname";
const username = faker.internet.email();
const firstname = faker.person.firstName();
const lastname = faker.person.lastName();
await removeUserByUsername(username);
await registerWithPasskey(page, firstname, lastname, username);
await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
});
test("register with username and password - only password enabled", async ({ page }) => {

View File

@@ -21,5 +21,9 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam
await page.goto("/register");
await registerUserScreenPasskey(page, firstname, lastname, email);
await page.getByTestId("submit-button").click();
// wait for projection of user
await page.waitForTimeout(2000);
return await passkeyRegister(page);
}

55
acceptance/tests/sink.ts Normal file
View File

@@ -0,0 +1,55 @@
import axios from "axios";
export async function getOtpFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.oTP;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function getCodeFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.code;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}

View File

@@ -1,7 +1,6 @@
import { Page } from "@playwright/test";
import axios from "axios";
import { registerWithPasskey } from "./register";
import { getUserByUsername, removeUser } from "./zitadel";
import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel";
export interface userProps {
email: string;
@@ -9,6 +8,7 @@ export interface userProps {
lastName: string;
organization: string;
password: string;
phone: string;
}
class User {
@@ -20,54 +20,13 @@ class User {
}
async ensure(page: Page) {
await this.remove();
const response = await addUser(this.props);
const body = {
username: this.props.email,
organization: {
orgId: this.props.organization,
},
profile: {
givenName: this.props.firstName,
familyName: this.props.lastName,
},
email: {
email: this.props.email,
isVerified: true,
},
password: {
password: this.props.password!,
},
};
try {
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users/human`, body, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400 && response.status !== 409) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
// wait for projection of user
await page.waitForTimeout(3000);
this.setUserId(response.userId);
}
async remove() {
const resp: any = await getUserByUsername(this.getUsername());
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
async cleanup() {
await removeUser(this.getUserId());
}
public setUserId(userId: string) {
@@ -94,12 +53,22 @@ class User {
return this.props.lastName;
}
public getPhone() {
return this.props.phone;
}
public getFullName() {
return `${this.props.firstName} ${this.props.lastName}`;
}
}
export class PasswordUser extends User {}
export class PasswordUser extends User {
async ensure(page: Page) {
await super.ensure(page);
// wait for projection of user
await page.waitForTimeout(2000);
}
}
export enum OtpType {
sms = "sms",
@@ -112,12 +81,12 @@ export interface otpUserProps {
lastName: string;
organization: string;
password: string;
phone: string;
type: OtpType;
}
export class PasswordUserWithOTP extends User {
private type: OtpType;
private code: string;
constructor(props: otpUserProps) {
super({
@@ -126,6 +95,7 @@ export class PasswordUserWithOTP extends User {
lastName: props.lastName,
organization: props.organization,
password: props.password,
phone: props.phone,
});
this.type = props.type;
}
@@ -133,47 +103,27 @@ export class PasswordUserWithOTP extends User {
async ensure(page: Page) {
await super.ensure(page);
let url = "otp_";
switch (this.type) {
case OtpType.sms:
url = url + "sms";
break;
case OtpType.email:
url = url + "email";
break;
}
await activateOTP(this.getUserId(), this.type);
try {
const response = await axios.post(
`${process.env.ZITADEL_API_URL}/v2/users/${this.getUserId()}/${url}`,
{},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
// wait for projection of user
await page.waitForTimeout(2000);
}
}
if (response.status >= 400 && response.status !== 409) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
export class PasswordUserWithTOTP extends User {
private secret: string;
// TODO: get code from SMS or Email provider
this.code = "";
} catch (error) {
console.error("Error making request:", error);
throw error;
}
async ensure(page: Page) {
await super.ensure(page);
this.secret = await addTOTP(this.getUserId());
// wait for projection of user
await page.waitForTimeout(2000);
}
public getCode() {
return this.code;
public getSecret(): string {
return this.secret;
}
}
@@ -182,6 +132,7 @@ export interface passkeyUserProps {
firstName: string;
lastName: string;
organization: string;
phone: string;
}
export class PasskeyUser extends User {
@@ -194,11 +145,11 @@ export class PasskeyUser extends User {
lastName: props.lastName,
organization: props.organization,
password: "",
phone: props.phone,
});
}
public async ensure(page: Page) {
await this.remove();
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
this.authenticatorId = authId;
@@ -206,8 +157,12 @@ export class PasskeyUser extends User {
await page.waitForTimeout(2000);
}
public async remove() {
await super.remove();
async cleanup() {
const resp: any = await getUserByUsername(this.getUsername());
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
}
public getAuthenticatorId(): string {

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
@@ -10,13 +11,15 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasskeyUser }>({
user: async ({ page }, use) => {
const user = new PasskeyUser({
email: "passkey@example.com",
firstName: "first",
lastName: "last",
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
@@ -25,16 +28,7 @@ test("username and passkey login", async ({ user, page }) => {
await loginScreenExpect(page, user.getFullName());
});
test("username and passkey login, if passkey enabled", async ({ user, page }) => {
// Given passkey is enabled on the organization of the user
// Given the user has only passkey enabled as authentication
// enter username
// passkey popup is directly shown
// user verifies passkey
// user is redirected to app
});
test("username and passkey login, multiple auth methods", async ({ user, page }) => {
test("username and passkey login, multiple auth methods", async ({ page }) => {
// Given passkey and password is enabled on the organization of the user
// Given the user has password and passkey registered
// enter username

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
@@ -12,14 +13,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: "password-changed@example.com",
firstName: "first",
lastName: "last",
password: "Password1!",
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
@@ -40,7 +43,7 @@ test("username and password changed login", async ({ user, page }) => {
*/
});
test("password not with desired complexity", async ({ user, page }) => {
test("password change not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change";
const changedPw2 = "chang";
await loginWithPassword(page, user.getUsername(), user.getPassword());

View File

@@ -1,6 +1,34 @@
import { test } from "@playwright/test";
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code, codeResend, otpFromSink } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
test("username, password and email otp login, enter code manually", async ({ page }) => {
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
type: OtpType.email,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and email otp login, enter code manually", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
@@ -8,6 +36,8 @@ test("username, password and email otp login, enter code manually", async ({ pag
// User receives an email with a verification code
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and email otp login, click link in email", async ({ page }) => {
@@ -20,7 +50,7 @@ test("username, password and email otp login, click link in email", async ({ pag
// User is redirected to the app (default redirect url)
});
test("username, password and email otp login, resend code", async ({ page }) => {
test("username, password and email otp login, resend code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
@@ -30,16 +60,24 @@ test("username, password and email otp login, resend code", async ({ page }) =>
// User receives a new email with a verification code
// User enters the new code in the ui
// User is redirected to the app (default redirect url)
await loginWithPassword(page, user.getUsername(), user.getPassword());
await codeResend(page);
await otpFromSink(page, user.getUsername());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and email otp login, wrong code", async ({ page }) => {
test("username, password and email otp login, wrong code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User enters a wrond code
// User enters a wrong code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});
test("username, password and email otp login, multiple mfa options", async ({ page }) => {
@@ -49,7 +87,7 @@ test("username, password and email otp login, multiple mfa options", async ({ pa
// User enters password
// User receives an email with a verification code
// User clicks button to use sms otp as second factor
// User receives an sms with a verification code
// User receives a sms with a verification code
// User enters code in ui
// User is redirected to the app (default redirect url)
});

View File

@@ -1,6 +1,34 @@
import { test } from "@playwright/test";
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
test("username, password and sms otp login", async ({ page }) => {
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
type: OtpType.sms,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and sms otp login, enter code manually", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
@@ -8,9 +36,11 @@ test("username, password and sms otp login", async ({ page }) => {
// User receives a sms with a verification code
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and sms otp login, resend code", async ({ page }) => {
test("username, password and sms otp login, resend code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
@@ -19,9 +49,11 @@ test("username, password and sms otp login, resend code", async ({ page }) => {
// User clicks resend code
// User receives a new sms with a verification code
// User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and sms otp login, wrong code", async ({ page }) => {
test("username, password and sms otp login, wrong code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
@@ -29,4 +61,8 @@ test("username, password and sms otp login, wrong code", async ({ page }) => {
// User receives a sms with a verification code
// User enters a wrong code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});

View File

@@ -0,0 +1,51 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPassword, startLogin } from "./login";
import { loginname } from "./loginname";
import { resetPassword, startResetPassword } from "./password";
import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password set login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807
const changedPw = "ChangedPw1!";
await startLogin(page);
await loginname(page, user.getUsername());
await resetPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
});
test("password set not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change";
const changedPw2 = "chang";
await startLogin(page);
await loginname(page, user.getUsername());
await startResetPassword(page);
await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2);
await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false);
});

View File

@@ -1,6 +1,33 @@
import { test } from "@playwright/test";
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login";
import { PasswordUserWithTOTP } from "./user";
test("username, password and totp login", async ({ page }) => {
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithTOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and totp login", async ({ user, page }) => {
// Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor
// User enters username
@@ -8,9 +35,11 @@ test("username, password and totp login", async ({ page }) => {
// Screen for entering the code is shown directly
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and totp otp login, wrong code", async ({ page }) => {
test("username, password and totp otp login, wrong code", async ({ user, page }) => {
// Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor
// User enters username
@@ -18,6 +47,10 @@ test("username, password and totp otp login, wrong code", async ({ page }) => {
// Screen for entering the code is shown directly
// User enters a wrond code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});
test("username, password and totp login, multiple mfa options", async ({ page }) => {

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
@@ -14,14 +15,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: "password@example.com",
firstName: "first",
lastName: "last",
password: "Password1!",
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});

View File

@@ -1,4 +1,34 @@
import { Authenticator } from "@otplib/core";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin
import axios from "axios";
import { OtpType, userProps } from "./user";
export async function addUser(props: userProps) {
const body = {
username: props.email,
organization: {
orgId: props.organization,
},
profile: {
givenName: props.firstName,
familyName: props.lastName,
},
email: {
email: props.email,
isVerified: true,
},
phone: {
phone: props.phone!,
isVerified: true,
},
password: {
password: props.password!,
},
};
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body);
}
export async function removeUserByUsername(username: string) {
const resp = await getUserByUsername(username);
@@ -9,8 +39,12 @@ export async function removeUserByUsername(username: string) {
}
export async function removeUser(id: string) {
await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`);
}
async function deleteCall(url: string) {
try {
const response = await axios.delete(`${process.env.ZITADEL_API_URL}/v2/users/${id}`, {
const response = await axios.delete(url, {
headers: {
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
@@ -27,7 +61,7 @@ export async function removeUser(id: string) {
}
}
export async function getUserByUsername(username: string) {
export async function getUserByUsername(username: string): Promise<any> {
const listUsersBody = {
queries: [
{
@@ -38,8 +72,12 @@ export async function getUserByUsername(username: string) {
],
};
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody);
}
async function listCall(url: string, data: any): Promise<any> {
try {
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody, {
const response = await axios.post(url, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
@@ -58,3 +96,64 @@ export async function getUserByUsername(username: string) {
throw error;
}
}
export async function activateOTP(userId: string, type: OtpType) {
let url = "otp_";
switch (type) {
case OtpType.sms:
url = url + "sms";
break;
case OtpType.email:
url = url + "email";
break;
}
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {});
}
async function pushCall(url: string, data: any) {
try {
const response = await axios.post(url, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function addTOTP(userId: string): Promise<string> {
const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {});
const code = totp(response.secret);
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code });
return response.secret;
}
export function totp(secret: string) {
const authenticator = new Authenticator({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
});
// google authenticator usage
const token = authenticator.generate(secret);
// check if token can be used
if (!authenticator.verify({ token: token, secret: secret })) {
const error = `Generated token could not be verified`;
console.error(error);
throw new Error(error);
}
return token;
}

View File

@@ -49,6 +49,7 @@ describe("login", () => {
data: {
settings: {
passkeysType: 1,
allowUsernamePassword: true,
},
},
});

View File

@@ -389,7 +389,10 @@ In future, self service options to jump to are shown below, like:
## Currently NOT Supported
- loginSettings.disableLoginWithEmail
- loginSettings.disableLoginWithPhone
- loginSettings.allowExternalIdp - this will be deprecated with the new login as it can be determined by the available IDPs
- loginSettings.forceMfaLocalOnly
Timebased features like the multifactor init prompt or password expiry, are not supported due to a current limitation in the API. Lockout settings which keeps track of the password retries, will also be implemented in a later stage.
- Lockout Settings
- Password Expiry Settings
- Login Settings: multifactor init prompt
- forceMFA on login settings is not checked for IDPs
- disablePhone / disableEmail from loginSettings will be implemented right after https://github.com/zitadel/zitadel/issues/9016 is merged

View File

@@ -74,6 +74,10 @@ export default async function Page(props: {
});
}
if (!sessionWithData) {
return <Alert>{tError("unknownContext")}</Alert>;
}
const branding = await getBrandingSettings(
sessionWithData.factors?.user?.organizationId,
);
@@ -82,22 +86,34 @@ export default async function Page(props: {
sessionWithData.factors?.user?.organizationId,
);
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
// const identityProviders = await getActiveIdentityProviders(
// sessionWithData.factors?.user?.organizationId,
// ).then((resp) => {
// return resp.identityProviders;
// });
const params = new URLSearchParams({
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (loginName) {
params.set("loginName", loginName);
if (sessionWithData.factors?.user?.loginName) {
params.set("loginName", sessionWithData.factors?.user?.loginName);
}
if (organization) {
params.set("organization", organization);
if (sessionWithData.factors?.user?.organizationId) {
params.set("organization", sessionWithData.factors?.user?.organizationId);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
@@ -105,18 +121,14 @@ export default async function Page(props: {
<p className="ztdl-p">{t("description")}</p>
{sessionWithData && (
<UserAvatar
loginName={loginName ?? sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<UserAvatar
loginName={sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
{!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>}
{loginSettings && sessionWithData && (
{loginSettings && (
<ChooseAuthenticatorToSetup
authMethods={sessionWithData.authMethods}
loginSettings={loginSettings}
@@ -124,6 +136,22 @@ export default async function Page(props: {
></ChooseAuthenticatorToSetup>
)}
{/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */}
{/* <p className="ztdl-p text-center">
or sign in with an Identity Provider
</p>
{loginSettings?.allowExternalIdp && identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={sessionWithData.factors?.user?.organizationId}
linkOnly={true} // tell the callback function to just link the IDP and not login, to get an error when user is already available
></SignInWithIdp>
)} */}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>

View File

@@ -37,7 +37,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" });
const { id, token, authRequestId, organization } = searchParams;
const { id, token, authRequestId, organization, link } = searchParams;
const { provider } = params;
const branding = await getBrandingSettings(organization);
@@ -50,7 +50,8 @@ export default async function Page(props: {
const { idpInformation, userId } = intent;
if (userId) {
// sign in user. If user should be linked continue
if (userId && !link) {
// TODO: update user if idp.options.isAutoUpdate is true
return (

View File

@@ -24,10 +24,6 @@ export default async function Page(props: {
const identityProviders = await getIdentityProviders(organization);
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
const branding = await getBrandingSettings(organization);
return (
@@ -38,7 +34,6 @@ export default async function Page(props: {
{identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={organization}

View File

@@ -2,23 +2,14 @@ import { DynamicTheme } from "@/components/dynamic-theme";
import { SignInWithIdp } from "@/components/sign-in-with-idp";
import { UsernameForm } from "@/components/username-form";
import {
getActiveIdentityProviders,
getBrandingSettings,
getDefaultOrg,
getLoginSettings,
settingsService,
} from "@/lib/zitadel";
import { makeReqCtx } from "@zitadel/client/v2";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
function getIdentityProviders(orgId?: string) {
return settingsService
.getActiveIdentityProviders({ ctx: makeReqCtx(orgId) }, {})
.then((resp) => {
return resp.identityProviders;
});
}
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
@@ -39,17 +30,15 @@ export default async function Page(props: {
}
}
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
const loginSettings = await getLoginSettings(
organization ?? defaultOrganization,
);
const identityProviders = await getIdentityProviders(
const identityProviders = await getActiveIdentityProviders(
organization ?? defaultOrganization,
);
).then((resp) => {
return resp.identityProviders;
});
const branding = await getBrandingSettings(
organization ?? defaultOrganization,
@@ -68,9 +57,8 @@ export default async function Page(props: {
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
>
{identityProviders && process.env.ZITADEL_API_URL && (
{identityProviders && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={organization ?? defaultOrganization} // use the organization from the searchParams here otherwise fallback to the default organization

View File

@@ -2,9 +2,15 @@ import { Alert } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { LoginOTP } from "@/components/login-otp";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel";
import {
getBrandingSettings,
getLoginSettings,
getSession,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
@@ -16,19 +22,44 @@ export default async function Page(props: {
const t = await getTranslations({ locale, namespace: "otp" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, authRequestId, sessionId, organization, code, submit } =
searchParams;
const {
loginName, // send from password page
userId, // send from email link
authRequestId,
sessionId,
organization,
code,
submit,
} = searchParams;
const { method } = params;
const session = await loadMostRecentSession({
loginName,
organization,
});
const session = sessionId
? await loadSessionById(sessionId, organization)
: await loadMostRecentSession({ loginName, organization });
const branding = await getBrandingSettings(organization);
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
}
const loginSettings = await getLoginSettings(organization);
// email links do not come with organization, thus we need to use the session's organization
const branding = await getBrandingSettings(
organization ?? session?.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(
organization ?? session?.factors?.user?.organizationId,
);
const host = (await headers()).get("host");
return (
<DynamicTheme branding={branding}>
@@ -59,14 +90,18 @@ export default async function Page(props: {
></UserAvatar>
)}
{method && (
{method && session && (
<LoginOTP
loginName={loginName}
loginName={loginName ?? session.factors?.user?.loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
organization={
organization ?? session?.factors?.user?.organizationId
}
method={method}
loginSettings={loginSettings}
host={host}
code={code}
></LoginOTP>
)}
</div>

View File

@@ -1,14 +1,17 @@
import { getAllSessions } from "@/lib/cookies";
import { idpTypeToSlug } from "@/lib/idp";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import {
createCallback,
getActiveIdentityProviders,
getAuthRequest,
getLoginSettings,
getOrgsByDomain,
listAuthenticationMethodTypes,
listSessions,
startIdentityProviderFlow,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { create, timestampDate } from "@zitadel/client";
import {
AuthRequest,
Prompt,
@@ -18,6 +21,7 @@ import {
SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
@@ -36,23 +40,143 @@ const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
function findSession(
sessions: Session[],
authRequest: AuthRequest,
): Session | undefined {
if (authRequest.hintUserId) {
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
/**
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
async function isSessionValid(session: Session): Promise<boolean> {
// session can't be checked without user
if (!session.factors?.user) {
console.warn("Session has no user");
return false;
}
if (authRequest.loginHint) {
console.log(`find session for loginHint: ${authRequest.loginHint}`);
return sessions.find(
(s) => s.factors?.user?.loginName === authRequest.loginHint,
let mfaValid = true;
const authMethodTypes = await listAuthenticationMethodTypes(
session.factors.user.id,
);
const authMethods = authMethodTypes.authMethodTypes;
if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) {
mfaValid = !!session.factors.totp?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid totpEmail factor",
session.factors.totp?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.OTP_EMAIL)
) {
mfaValid = !!session.factors.otpEmail?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid otpEmail factor",
session.factors.otpEmail?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.OTP_SMS)
) {
mfaValid = !!session.factors.otpSms?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid otpSms factor",
session.factors.otpSms?.verifiedAt,
);
}
} else if (
authMethods &&
authMethods.includes(AuthenticationMethodType.U2F)
) {
mfaValid = !!session.factors.webAuthN?.verifiedAt;
if (!mfaValid) {
console.warn(
"Session has no valid u2f factor",
session.factors.webAuthN?.verifiedAt,
);
}
} else {
// only check settings if no auth methods are available, as this would require a setup
const loginSettings = await getLoginSettings(
session.factors?.user?.organizationId,
);
if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) {
const otpEmail = session.factors.otpEmail?.verifiedAt;
const otpSms = session.factors.otpSms?.verifiedAt;
const totp = session.factors.totp?.verifiedAt;
const webAuthN = session.factors.webAuthN?.verifiedAt;
// must have one single check
mfaValid = !!(otpEmail || otpSms || totp || webAuthN);
if (!mfaValid) {
console.warn(
"Session has no valid multifactor",
JSON.stringify(session.factors),
);
}
} else {
mfaValid = true;
}
}
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate
? timestampDate(session.expirationDate).getTime() > new Date().getTime()
: true;
if (!stillValid) {
console.warn(
"Session is expired",
session.expirationDate
? timestampDate(session.expirationDate).toDateString()
: "no expiration date",
);
}
if (sessions.length) {
return sessions[0];
const validChecks = !!(validPassword || validPasskey || validIDP);
return stillValid && validChecks && mfaValid;
}
async function findValidSession(
sessions: Session[],
authRequest: AuthRequest,
): Promise<Session | undefined> {
const sessionsWithHint = sessions.filter((s) => {
if (authRequest.hintUserId) {
return s.factors?.user?.id === authRequest.hintUserId;
}
if (authRequest.loginHint) {
return s.factors?.user?.loginName === authRequest.loginHint;
}
return true;
});
if (sessionsWithHint.length === 0) {
return undefined;
}
// sort by change date descending
sessionsWithHint.sort((a, b) => {
const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0;
const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0;
return dateB - dateA;
});
// return the first valid session according to settings
for (const session of sessionsWithHint) {
if (await isSessionValid(session)) {
return session;
}
}
return undefined;
}
@@ -74,22 +198,34 @@ export async function GET(request: NextRequest) {
sessions = await loadSessions(ids);
}
/**
* TODO: before automatically redirecting to the callbackUrl, check if the session is still valid
* possible scenaio:
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
if (authRequestId && sessionId) {
console.log(
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
);
let selectedSession = sessions.find((s) => s.id === sessionId);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
console.log(`Found session ${selectedSession.id}`);
const isValid = await isSessionValid(selectedSession);
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
authRequestId: authRequestId,
};
const res = await sendLoginname(command);
if (res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
@@ -119,8 +255,41 @@ export async function GET(request: NextRequest) {
{ status: 500 },
);
}
} catch (error) {
return NextResponse.json({ error }, { status: 500 });
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with authRequestId are used (reset password, register emails etc.)
console.error(error);
if (
error &&
typeof error === "object" &&
"code" in error &&
error?.code === 9
) {
const loginSettings = await getLoginSettings(
selectedSession.factors?.user?.organizationId,
);
if (loginSettings?.defaultRedirectUri) {
return NextResponse.redirect(loginSettings.defaultRedirectUri);
}
const signedinUrl = new URL("/signedin", request.url);
if (selectedSession.factors?.user?.loginName) {
signedinUrl.searchParams.set(
"loginName",
selectedSession.factors?.user?.loginName,
);
}
if (selectedSession.factors?.user?.organizationId) {
signedinUrl.searchParams.set(
"organization",
selectedSession.factors?.user?.organizationId,
);
}
return NextResponse.redirect(signedinUrl);
} else {
return NextResponse.json({ error }, { status: 500 });
}
}
}
}
@@ -174,7 +343,7 @@ export async function GET(request: NextRequest) {
const idp = identityProviders.find((idp) => idp.id === idpId);
if (idp) {
const host = request.nextUrl.origin;
const origin = request.nextUrl.origin;
const identityProviderType = identityProviders[0].type;
let provider = idpTypeToSlug(identityProviderType);
@@ -193,10 +362,10 @@ export async function GET(request: NextRequest) {
idpId,
urls: {
successUrl:
`${host}/idp/${provider}/success?` +
`${origin}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` +
`${origin}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
}).then((resp) => {
@@ -225,8 +394,8 @@ export async function GET(request: NextRequest) {
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
const registerUrl = new URL("/register", request.url);
if (authRequest?.id) {
registerUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.id) {
registerUrl.searchParams.set("authRequestId", authRequest.id);
}
if (organization) {
registerUrl.searchParams.set("organization", organization);
@@ -241,10 +410,36 @@ export async function GET(request: NextRequest) {
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
return gotoAccounts();
} else if (authRequest.prompt.includes(Prompt.LOGIN)) {
// if prompt is login
/**
* The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated
*/
// if a hint is provided, skip loginname page and jump to the next page
if (authRequest.loginHint) {
try {
let command: SendLoginnameCommand = {
loginName: authRequest.loginHint,
authRequestId: authRequest.id,
};
if (organization) {
command = { ...command, organization };
}
const res = await sendLoginname(command);
if (res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
} catch (error) {
console.error("Failed to execute sendLoginname:", error);
}
}
const loginNameUrl = new URL("/loginname", request.url);
if (authRequest?.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
}
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
@@ -254,82 +449,87 @@ export async function GET(request: NextRequest) {
}
return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) {
// NONE prompt - silent authentication
/**
* With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages.
* This means that the user should not be prompted to enter their password again.
* Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction
**/
const selectedSession = await findValidSession(sessions, authRequest);
let selectedSession = findSession(sessions, authRequest);
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
return NextResponse.redirect(callbackUrl);
} else {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 }, // TODO: check for correct status code
);
}
} else {
if (!selectedSession || !selectedSession.id) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 }, // TODO: check for correct status code
{ status: 400 },
);
}
} else {
// check for loginHint, userId hint sessions
let selectedSession = findSession(sessions, authRequest);
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id,
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession.id,
);
if (!cookie || !cookie.id || !cookie.token) {
return NextResponse.json(
{ error: "No active session found" },
{ status: 400 },
);
}
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
try {
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
console.log(
"could not create callback, redirect user to choose other account",
);
return gotoAccounts();
}
} catch (error) {
console.error(error);
return gotoAccounts();
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
return NextResponse.redirect(callbackUrl);
} else {
// check for loginHint, userId hint and valid sessions
let selectedSession = await findValidSession(sessions, authRequest);
if (!selectedSession || !selectedSession.id) {
return gotoAccounts();
}
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession.id,
);
if (!cookie || !cookie.id || !cookie.token) {
return gotoAccounts();
}
const session = {
sessionId: cookie.id,
sessionToken: cookie.token,
};
try {
const { callbackUrl } = await createCallback(
create(CreateCallbackRequestSchema, {
authRequestId,
callbackKind: {
case: "session",
value: create(SessionSchema, session),
},
}),
);
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
console.log(
"could not create callback, redirect user to choose other account",
);
return gotoAccounts();
}
} else {
} catch (error) {
console.error(error);
return gotoAccounts();
}
}

View File

@@ -12,6 +12,7 @@ import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
@@ -44,6 +45,7 @@ export function ChangePasswordForm({
organization,
}: Props) {
const t = useTranslations("password");
const router = useRouter();
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur",
@@ -107,6 +109,14 @@ export function ChangePasswordForm({
return;
}
if (
passwordResponse &&
"redirect" in passwordResponse &&
passwordResponse.redirect
) {
return router.push(passwordResponse.redirect);
}
return;
}

View File

@@ -25,6 +25,7 @@ type Props = {
method: string;
code?: string;
loginSettings?: LoginSettings;
host: string | null;
};
type Inputs = {
@@ -39,6 +40,7 @@ export function LoginOTP({
method,
code,
loginSettings,
host,
}: Props) {
const t = useTranslations("otp");
@@ -57,7 +59,7 @@ export function LoginOTP({
});
useEffect(() => {
if (!initialized.current && ["email", "sms"].includes(method)) {
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
initialized.current = true;
setLoading(true);
updateSessionForOTPChallenge()
@@ -76,13 +78,24 @@ export function LoginOTP({
if (method === "email") {
challenges = create(RequestChallengesSchema, {
otpEmail: { deliveryType: { case: "sendCode", value: {} } },
otpEmail: {
deliveryType: {
case: "sendCode",
value: host
? {
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
}
: {},
},
},
});
}
if (method === "sms") {
challenges = create(RequestChallengesSchema, {
otpSms: { returnCode: true },
otpSms: {},
});
}
@@ -94,14 +107,19 @@ export function LoginOTP({
challenges,
authRequestId,
})
.catch((error) => {
setError(error.message ?? "Could not request OTP challenge");
.catch(() => {
setError("Could not request OTP challenge");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}
@@ -154,12 +172,21 @@ export function LoginOTP({
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}
function setCodeAndContinue(values: Inputs, organization?: string) {
return submitCode(values, organization).then(async (response) => {
if (response) {
if (response && "sessionId" in response) {
setLoading(true);
// Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint
await new Promise((resolve) => setTimeout(resolve, 2000));
const url =
authRequestId && response.sessionId
? await getNextUrl(
@@ -180,6 +207,7 @@ export function LoginOTP({
)
: null;
setLoading(false);
if (url) {
router.push(url);
}
@@ -198,6 +226,7 @@ export function LoginOTP({
<button
aria-label="Resend OTP Code"
disabled={loading}
type="button"
className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
onClick={() => {
setLoading(true);
@@ -210,6 +239,7 @@ export function LoginOTP({
setLoading(false);
});
}}
data-testid="resend-button"
>
{t("verify.resendCode")}
</button>
@@ -222,11 +252,12 @@ export function LoginOTP({
{...register("code", { required: "This field is required" })}
label="Code"
autoComplete="one-time-code"
data-testid="code-text-input"
/>
</div>
{error && (
<div className="py-4">
<div className="py-4" data-testid="error">
<Alert>{error}</Alert>
</div>
)}

View File

@@ -110,6 +110,11 @@ export function LoginPasskey({
setLoading(false);
});
if (session && "error" in session && session.error) {
setError(session.error);
return;
}
return session;
}
@@ -132,6 +137,11 @@ export function LoginPasskey({
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}

View File

@@ -59,7 +59,6 @@ export function PasswordForm({
password: { password: values.password },
}),
authRequestId,
forceMfa: loginSettings?.forceMfa,
})
.catch(() => {
setError("Could not verify password");
@@ -87,6 +86,7 @@ export function PasswordForm({
const response = await resetPassword({
loginName,
organization,
authRequestId,
})
.catch(() => {
setError("Could not reset password");
@@ -134,6 +134,7 @@ export function PasswordForm({
onClick={() => resetPasswordAndContinue()}
type="button"
disabled={loading}
data-testid="reset-button"
>
{t("verify.resetPassword")}
</button>

View File

@@ -16,12 +16,14 @@ export function isSessionValid(session: Partial<Session>): {
} {
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validIDP = session?.factors?.intent?.verifiedAt;
const stillValid = session.expirationDate
? timestampDate(session.expirationDate) > new Date()
: true;
const verifiedAt = validPassword || validPasskey;
const valid = !!((validPassword || validPasskey) && stillValid);
const verifiedAt = validPassword || validPasskey || validIDP;
const valid = !!((validPassword || validPasskey || validIDP) && stillValid);
return { valid, verifiedAt };
}
@@ -63,10 +65,14 @@ export function SessionItem({
<button
onClick={async () => {
if (valid && session?.factors?.user) {
return continueWithSession({
const resp = await continueWithSession({
...session,
authRequestId: authRequestId,
});
if (resp?.redirect) {
return router.push(resp.redirect);
}
} else if (session.factors?.user) {
setLoading(true);
const res = await sendLoginname({
@@ -102,15 +108,23 @@ export function SessionItem({
/>
</div>
<div className="flex flex-col overflow-hidden">
<div className="flex flex-col items-start overflow-hidden">
<span className="">{session.factors?.user?.displayName}</span>
<span className="text-xs opacity-80 text-ellipsis">
{session.factors?.user?.loginName}
</span>
{valid && (
{valid ? (
<span className="text-xs opacity-80 text-ellipsis">
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
</span>
) : (
verifiedAt && (
<span className="text-xs opacity-80 text-ellipsis">
expired{" "}
{session.expirationDate &&
moment(timestampDate(session.expirationDate)).fromNow()}
</span>
)
)}
</div>
@@ -126,6 +140,7 @@ export function SessionItem({
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearSession(session.id).then(() => {
reload();
});

View File

@@ -1,6 +1,6 @@
"use client";
import { timestampMs } from "@zitadel/client";
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl";
import { useState } from "react";
@@ -12,14 +12,6 @@ type Props = {
authRequestId?: string;
};
function sortFc(a: Session, b: Session) {
if (a.changeDate && b.changeDate) {
return timestampMs(a.changeDate) - timestampMs(b.changeDate);
} else {
return 0;
}
}
export function SessionsList({ sessions, authRequestId }: Props) {
const t = useTranslations("accounts");
const [list, setList] = useState<Session[]>(sessions);
@@ -27,7 +19,17 @@ export function SessionsList({ sessions, authRequestId }: Props) {
<div className="flex flex-col space-y-2">
{list
.filter((session) => session?.factors?.user?.loginName)
.sort(sortFc)
// sort by change date descending
.sort((a, b) => {
const dateA = a.changeDate
? timestampDate(a.changeDate).getTime()
: 0;
const dateB = b.changeDate
? timestampDate(b.changeDate).getTime()
: 0;
return dateB - dateA;
})
// TODO: add sorting to move invalid sessions to the bottom
.map((session, index) => {
return (
<SessionItem

View File

@@ -6,7 +6,11 @@ import {
symbolValidator,
upperCaseValidator,
} from "@/helpers/validators";
import { changePassword, sendPassword } from "@/lib/server/password";
import {
changePassword,
resetPassword,
sendPassword,
} from "@/lib/server/password";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
@@ -62,6 +66,29 @@ export function SetPasswordForm({
const router = useRouter();
async function resendCode() {
setError("");
setLoading(true);
const response = await resetPassword({
loginName,
organization,
authRequestId,
})
.catch(() => {
setError("Could not reset password");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response) {
setError(response.error);
return;
}
}
async function submitPassword(values: Inputs) {
setLoading(true);
let payload: { userId: string; password: string; code?: string } = {
@@ -176,11 +203,17 @@ export function SetPasswordForm({
label="Code"
autoComplete="one-time-code"
error={errors.code?.message as string}
data-testid="code-text-input"
/>
</div>
<div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}>
<Button
variant={ButtonVariants.Secondary}
data-testid="resend-button"
onClick={() => resendCode()}
disabled={loading}
>
{t("set.resend")}
</Button>
</div>
@@ -196,6 +229,7 @@ export function SetPasswordForm({
})}
label="New Password"
error={errors.password?.message as string}
data-testid="password-text-input"
/>
</div>
<div className="">
@@ -208,6 +242,7 @@ export function SetPasswordForm({
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
data-testid="password-confirm-text-input"
/>
</div>
</div>

View File

@@ -18,17 +18,17 @@ import { SignInWithGoogle } from "./idps/sign-in-with-google";
export interface SignInWithIDPProps {
children?: ReactNode;
host: string;
identityProviders: IdentityProvider[];
authRequestId?: string;
organization?: string;
linkOnly?: boolean;
}
export function SignInWithIdp({
host,
identityProviders,
authRequestId,
organization,
linkOnly,
}: SignInWithIDPProps) {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
@@ -39,6 +39,10 @@ export function SignInWithIdp({
const params = new URLSearchParams();
if (linkOnly) {
params.set("link", "true");
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
@@ -49,10 +53,8 @@ export function SignInWithIdp({
const response = await startIDPFlow({
idpId,
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
successUrl: `/idp/${provider}/success?` + new URLSearchParams(params),
failureUrl: `/idp/${provider}/failure?` + new URLSearchParams(params),
})
.catch(() => {
setError("Could not start IDP flow");
@@ -62,6 +64,11 @@ export function SignInWithIdp({
setLoading(false);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response?.redirect) {
return router.push(response.redirect);
}
@@ -70,121 +77,136 @@ export function SignInWithIdp({
return (
<div className="flex flex-col w-full space-y-2 text-sm">
{identityProviders &&
identityProviders.map((idp, i) => {
switch (idp.type) {
case IdentityProviderType.APPLE:
return (
<SignInWithApple
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.APPLE))
}
></SignInWithApple>
);
case IdentityProviderType.OAUTH:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OAUTH))
}
></SignInWithGeneric>
);
case IdentityProviderType.OIDC:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OIDC))
}
></SignInWithGeneric>
);
case IdentityProviderType.GITHUB:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB),
)
}
></SignInWithGithub>
);
case IdentityProviderType.GITHUB_ES:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
)
}
></SignInWithGithub>
);
case IdentityProviderType.AZURE_AD:
return (
<SignInWithAzureAd
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.AZURE_AD),
)
}
></SignInWithAzureAd>
);
case IdentityProviderType.GOOGLE:
return (
<SignInWithGoogle
key={`idp-${i}`}
e2e="google"
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GOOGLE),
)
}
></SignInWithGoogle>
);
case IdentityProviderType.GITLAB:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB),
)
}
></SignInWithGitlab>
);
case IdentityProviderType.GITLAB_SELF_HOSTED:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
)
}
></SignInWithGitlab>
);
default:
return null;
}
})}
identityProviders
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
// .filter((idp) =>
// linkOnly ? idp.config?.options.isLinkingAllowed : true,
// )
.map((idp, i) => {
switch (idp.type) {
case IdentityProviderType.APPLE:
return (
<SignInWithApple
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.APPLE),
)
}
></SignInWithApple>
);
case IdentityProviderType.OAUTH:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.OAUTH),
)
}
></SignInWithGeneric>
);
case IdentityProviderType.OIDC:
return (
<SignInWithGeneric
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.OIDC),
)
}
></SignInWithGeneric>
);
case IdentityProviderType.GITHUB:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB),
)
}
></SignInWithGithub>
);
case IdentityProviderType.GITHUB_ES:
return (
<SignInWithGithub
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
)
}
></SignInWithGithub>
);
case IdentityProviderType.AZURE_AD:
return (
<SignInWithAzureAd
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.AZURE_AD),
)
}
></SignInWithAzureAd>
);
case IdentityProviderType.GOOGLE:
return (
<SignInWithGoogle
key={`idp-${i}`}
e2e="google"
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GOOGLE),
)
}
></SignInWithGoogle>
);
case IdentityProviderType.GITLAB:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB),
)
}
></SignInWithGitlab>
);
case IdentityProviderType.GITLAB_SELF_HOSTED:
return (
<SignInWithGitlab
key={`idp-${i}`}
name={idp.name}
onClick={() =>
startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
)
}
></SignInWithGitlab>
);
default:
return null;
}
})}
{error && (
<div className="py-4">
<Alert>{error}</Alert>

View File

@@ -15,24 +15,29 @@ export async function getNextUrl(
defaultRedirectUri?: string,
): Promise<string> {
if ("sessionId" in command && "authRequestId" in command) {
const url =
`/login?` +
new URLSearchParams({
sessionId: command.sessionId,
authRequest: command.authRequestId,
});
return url;
const params = new URLSearchParams({
sessionId: command.sessionId,
authRequest: command.authRequestId,
});
if (command.organization) {
params.append("organization", command.organization);
}
return `/login?` + params;
}
if (defaultRedirectUri) {
return defaultRedirectUri;
}
const signedInUrl =
`/signedin?` +
new URLSearchParams({
loginName: command.loginName,
});
const params = new URLSearchParams({
loginName: command.loginName,
});
return signedInUrl;
if (command.organization) {
params.append("organization", command.organization);
}
return `/signedin?` + params;
}

View File

@@ -142,7 +142,7 @@ export async function removeSessionFromCookie<T>(
}
}
export async function getMostRecentSessionCookie<T>(): Promise<any> {
export async function getMostRecentSessionCookie<T>(): Promise<Cookie> {
const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions");

View File

@@ -84,8 +84,8 @@ type resendVerifyEmailCommand = {
export async function resendVerification(command: resendVerifyEmailCommand) {
return command.isInvite
? resendEmailCode(command.userId)
: resendInviteCode(command.userId);
? resendInviteCode(command.userId)
: resendEmailCode(command.userId);
}
export async function sendVerificationRedirectWithoutCheck(command: {

View File

@@ -1,6 +1,7 @@
"use server";
import { startIdentityProviderFlow } from "@/lib/zitadel";
import { headers } from "next/headers";
export type StartIDPFlowCommand = {
idpId: string;
@@ -9,11 +10,17 @@ export type StartIDPFlowCommand = {
};
export async function startIDPFlow(command: StartIDPFlowCommand) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
return startIdentityProviderFlow({
idpId: command.idpId,
urls: {
successUrl: command.successUrl,
failureUrl: command.failureUrl,
successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.successUrl}`,
failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.failureUrl}`,
},
}).then((response) => {
if (

View File

@@ -2,11 +2,12 @@
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -35,6 +36,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const loginSettings = await getLoginSettings(command.organization);
const potentialUsers = users.result.filter((u) => {
const human = u.type.case === "human" ? u.type.value : undefined;
return loginSettings?.disableLoginWithEmail
? human?.email?.isVerified && human?.email?.email !== command.loginName
: loginSettings?.disableLoginWithPhone
? human?.phone?.isVerified && human?.phone?.phone !== command.loginName
: true;
});
const redirectUserToSingleIDPIfAvailable = async () => {
const identityProviders = await getActiveIdentityProviders(
command.organization,
@@ -44,6 +54,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (identityProviders.length === 1) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
const identityProviderType = identityProviders[0].type;
const provider = idpTypeToSlug(identityProviderType);
@@ -62,9 +77,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
idpId: identityProviders[0].id,
urls: {
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
@@ -81,9 +98,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (identityProviders.length === 1) {
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
const identityProviderId = identityProviders[0].idpId;
const idp = await getIDPByID(identityProviderId);
const idpType = idp?.type;
if (!idp || !idpType) {
@@ -107,9 +130,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
idpId: idp.id,
urls: {
successUrl:
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
new URLSearchParams(params),
failureUrl:
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
@@ -119,8 +144,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
};
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
const userId = users.result[0].userId;
if (potentialUsers.length == 1 && potentialUsers[0].userId) {
const userId = potentialUsers[0].userId;
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } },
@@ -136,24 +161,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { error: "Could not create session for user" };
}
if (users.result[0].state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
initial: "true", // this does not require a code to be set
});
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
if (command.authRequestId) {
params.append("authRequestid", command.authRequestId);
}
return { redirect: "/password/set?" + params };
// TODO: check if handling of userstate INITIAL is needed
if (potentialUsers[0].state === UserState.INITIAL) {
return { error: "Initial User not supported" };
}
const methods = await listAuthenticationMethodTypes(
@@ -162,9 +172,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
if (
users.result[0].type.case === "human" &&
users.result[0].type.value.email &&
!users.result[0].type.value.email.isVerified
potentialUsers[0].type.case === "human" &&
potentialUsers[0].type.value.email &&
!potentialUsers[0].type.value.email.isVerified
) {
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName,
@@ -209,6 +219,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const method = methods.authMethodTypes[0];
switch (method) {
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
if (!loginSettings?.allowUsernamePassword) {
return {
error:
"Username Password not allowed! Contact your administrator for more information.",
};
}
const paramsPassword: any = {
loginName: session.factors?.user?.loginName,
};
@@ -229,6 +246,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
};
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
if (loginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) {
return {
error:
"Passkeys not allowed! Contact your administrator for more information.",
};
}
const paramsPasskey: any = { loginName: command.loginName };
if (command.authRequestId) {
paramsPasskey.authRequestId = command.authRequestId;
@@ -262,7 +286,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} else if (
methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
) {
await redirectUserToIDP(userId);
return redirectUserToIDP(userId);
} else if (
methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)
) {
@@ -288,8 +312,10 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// user not found, check if register is enabled on organization
if (loginSettings?.allowRegister && !loginSettings?.allowUsernamePassword) {
// TODO: do we need to handle login hints for IDPs here?
await redirectUserToSingleIDPIfAvailable();
const resp = await redirectUserToSingleIDPIfAvailable();
if (resp) {
return resp;
}
return { error: "Could not find user" };
} else if (
loginSettings?.allowRegister &&

View File

@@ -27,6 +27,7 @@ import { getSessionCookieByLoginName } from "../cookies";
type ResetPasswordCommand = {
loginName: string;
organization?: string;
authRequestId?: string;
};
export async function resetPassword(command: ResetPasswordCommand) {
@@ -46,7 +47,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
}
const userId = users.result[0].userId;
return passwordReset(userId, host);
return passwordReset(userId, host, command.authRequestId);
}
export type UpdateSessionCommand = {
@@ -54,7 +55,6 @@ export type UpdateSessionCommand = {
organization?: string;
checks: Checks;
authRequestId?: string;
forceMfa?: boolean;
};
export async function sendPassword(command: UpdateSessionCommand) {
@@ -148,6 +148,27 @@ export async function sendPassword(command: UpdateSessionCommand) {
m !== AuthenticationMethodType.PASSKEY,
);
const humanUser = user.type.case === "human" ? user.type.value : undefined;
if (
availableSecondFactors?.length == 0 &&
humanUser?.passwordChangeRequired
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
});
if (command.organization || session.factors?.user?.organizationId) {
params.append("organization", session.factors?.user?.organizationId);
}
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
return { redirect: "/password/change?" + params };
}
if (availableSecondFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user.loginName,
@@ -192,24 +213,14 @@ export async function sendPassword(command: UpdateSessionCommand) {
}
return { redirect: `/mfa?` + params };
} else if (user.state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
return { redirect: `/password/change?` + params };
} else if (command.forceMfa && !availableSecondFactors.length) {
}
// TODO: check if handling of userstate INITIAL is needed
else if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableSecondFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
force: "true", // this defines if the mfa is forced in the settings

View File

@@ -15,7 +15,6 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getNextUrl } from "../client";
import {
getMostRecentSessionCookie,
@@ -108,7 +107,7 @@ export async function continueWithSession({
)
: null;
if (url) {
return redirect(url);
return { redirect: url };
}
}
@@ -132,21 +131,23 @@ export async function updateSession(options: UpdateSessionCommand) {
challenges,
} = options;
const recentSession = sessionId
? await getSessionCookieById({ sessionId }).catch((error) => {
return Promise.reject(error);
})
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization }).catch(
(error) => {
return Promise.reject(error);
},
)
: await getMostRecentSessionCookie().catch((error) => {
return Promise.reject(error);
});
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
if (!recentSession) {
return {
error: "Could not find session",
};
}
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
if (
host &&
challenges &&
@@ -174,6 +175,10 @@ export async function updateSession(options: UpdateSessionCommand) {
lifetime,
);
if (!session) {
return { error: "Could not update session" };
}
// if password, check if user has MFA methods
let authMethods;
if (checks && checks.password && session.factors?.user?.id) {

View File

@@ -445,11 +445,6 @@ export async function verifyEmail(userId: string, verificationCode: string) {
);
}
/**
*
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function resendEmailCode(userId: string) {
return userService.resendEmailCode(
{
@@ -504,7 +499,11 @@ export function createUser(
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function passwordReset(userId: string, host: string | null) {
export async function passwordReset(
userId: string,
host: string | null,
authRequestId?: string,
) {
let medium = create(SendPasswordResetLinkSchema, {
notificationType: NotificationType.Email,
});
@@ -512,7 +511,9 @@ export async function passwordReset(userId: string, host: string | null) {
if (host) {
medium = {
...medium,
urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
};
}

View File

@@ -23,7 +23,9 @@
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build --filter=login^... && changeset publish",
"run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup"
"run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup",
"run-sink": "docker compose -f ./acceptance/docker-compose.yaml up -d sink",
"stop": "docker compose -f ./acceptance/docker-compose.yaml stop"
},
"pnpm": {
"overrides": {
@@ -33,6 +35,10 @@
}
},
"devDependencies": {
"@otplib/core": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@faker-js/faker": "^9.2.0",
"@changesets/cli": "^2.27.9",
"@playwright/test": "^1.48.2",
"@types/node": "^22.9.0",

View File

@@ -72,6 +72,7 @@ export default defineConfig({
],
/* Run local dev server before starting the tests */
webServer: {
command: "pnpm start:built",
url: "http://127.0.0.1:3000",

44
pnpm-lock.yaml generated
View File

@@ -16,6 +16,18 @@ importers:
'@changesets/cli':
specifier: ^2.27.9
version: 2.27.9
'@faker-js/faker':
specifier: ^9.2.0
version: 9.2.0
'@otplib/core':
specifier: ^12.0.0
version: 12.0.1
'@otplib/plugin-crypto':
specifier: ^12.0.0
version: 12.0.1
'@otplib/plugin-thirty-two':
specifier: ^12.0.0
version: 12.0.1
'@playwright/test':
specifier: ^1.48.2
version: 1.48.2
@@ -919,6 +931,10 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@faker-js/faker@9.2.0':
resolution: {integrity: sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
'@floating-ui/core@1.6.8':
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
@@ -1211,6 +1227,15 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@otplib/core@12.0.1':
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
'@otplib/plugin-crypto@12.0.1':
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
'@otplib/plugin-thirty-two@12.0.1':
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
'@parcel/watcher-android-arm64@2.5.0':
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
engines: {node: '>= 10.0.0'}
@@ -4376,6 +4401,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
@@ -5438,6 +5467,8 @@ snapshots:
'@eslint/js@8.57.1': {}
'@faker-js/faker@9.2.0': {}
'@floating-ui/core@1.6.8':
dependencies:
'@floating-ui/utils': 0.2.8
@@ -5717,6 +5748,17 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-thirty-two@12.0.1':
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
'@parcel/watcher-android-arm64@2.5.0':
optional: true
@@ -9136,6 +9178,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
thirty-two@1.0.2: {}
throttleit@1.0.1: {}
through@2.3.8: {}