Skip to content

Commit 885f091

Browse files
authored
feat(authentication): temporary access link with bearer token (#1742)
## Done Suggested flow: 1. User runs lxd init | CLI outputs https://<ip>:8443/?token=xyz... 2. User opens link | UI detects token in URL, saves to Auth state, redirects to Dashboard. 3. The Warning | Status bar appears: ⚠️ Temporary session. Expires in 23 hours 59 minutes 10 seconds. 4. User clicks "Setup permanent access" | UI navigates to Authentication setup page. 5. Selection | User chooses OIDC or TLS to "lock in" access. 6. Clean up | On successful login with TLS, UI calls logout endpoint to remove now unnecessary bearer token. ## QA 1. Run the LXD-UI: - On the demo server via the link posted by @webteam-app below. This is only available for PRs created by collaborators of the repo. Ask @Kxiru or @edlerd for access. - With a local copy of this branch, [build and run as described in the docs](https://github.com/canonical/lxd-ui/blob/main/CONTRIBUTING.md#setting-up-for-development). 2. Perform the following QA steps: - Backend should be latest edge. Run `lxd init --ui-temporary-access-link` - Click on the link generated by backend. It should take you to LXD UI. Ensure you have access to everything. - Note the beautiful warning at the bottom of the page with a count down. - Manually logout. Ensure you can log back in with same link. - Ensure you can not log in with an expired or invalid token, you should see an error notification with action: logout. Click on logout and make sure the bearer token cookie is removed from your browser. - Log back in with the bearer token. Click on `Setup permanent access` > `Setup TLS login`. If not already done, import certificate in your browser, otherwise go to step 2. Make sure the name is pre-filled and `admins` group is pre-checked. Click on `Create identity`, you should be automatically logged in with TLS (you can check Permissions > Identities). Ensure the bearer token was automatically removed. ## Screenshots Warning in status bar at the bottom of the screen with countdown every second <img width="1847" height="1065" alt="image" src="https://github.com/user-attachments/assets/35004d61-6ddf-4645-93c7-93ad47b266cd" /> Authentication setup screen (after clicking on `Setup permanent access`) <img width="1851" height="1053" alt="image" src="https://github.com/user-attachments/assets/20731aad-8176-4951-8dd0-19aa874e8ee3" /> Updated Login page to match Authentication setup page <img width="1851" height="1053" alt="Screenshot from 2026-02-20 10-11-36" src="https://github.com/user-attachments/assets/8c0ae261-a470-45c7-b2e0-11077dbaaf90" /> CertificateGenerate with bearer token <img width="1851" height="1053" alt="image" src="https://github.com/user-attachments/assets/676d3665-15dd-49aa-a9ae-3f6ed3005596" /> Create TLS identity screen with bearer token <img width="1851" height="1053" alt="image" src="https://github.com/user-attachments/assets/918e20ce-182e-47ca-ab5a-4b023528e731" />
2 parents 4f300ca + f7166c2 commit 885f091

39 files changed

Lines changed: 1090 additions & 446 deletions

haproxy-dev.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ frontend lxd_frontend
1919
acl is_core path_beg /1.0
2020
acl is_oidc path_beg /oidc
2121
acl is_docs path_beg /documentation
22-
use_backend lxd_core if is_core || is_oidc || is_docs
22+
acl is_bearer path_beg /bearer
23+
use_backend lxd_core if is_core || is_oidc || is_docs || is_bearer
2324
default_backend lxd_ui
2425

2526
backend lxd_ui

src/App.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { FC } from "react";
2-
import { useEffect } from "react";
3-
import { Suspense } from "react";
2+
import { Suspense, useEffect } from "react";
43
import { Navigate, Route, Routes } from "react-router-dom";
54
import ProjectRedirect from "pages/projects/ProjectRedirect";
65
import ProjectLoader from "pages/projects/ProjectLoader";
76
import { useAuth } from "context/auth";
87
import { setTitle } from "util/title";
98
import NoMatch from "components/NoMatch";
10-
import { logout } from "util/helpers";
9+
import { isBearerAuthError, logoutBearerToken, logoutOidc } from "util/helpers";
1110
import { ROOT_PATH } from "util/rootPath";
1211
import lazy from "util/lazyWithRetry";
1312
import { useSettings } from "context/useSettings";
@@ -21,7 +20,11 @@ import {
2120
} from "@canonical/react-components";
2221
import { setFavicon } from "util/favicon";
2322
import { ALL_PROJECTS } from "util/loginProject";
23+
import { AUTH_METHOD } from "util/authentication";
2424

25+
const AuthenticationSetup = lazy(
26+
async () => import("pages/login/AuthenticationSetup"),
27+
);
2528
const CertificateAdd = lazy(async () => import("pages/login/CertificateAdd"));
2629
const CertificateGenerate = lazy(
2730
async () => import("pages/login/CertificateGenerate"),
@@ -125,7 +128,7 @@ const App: FC = () => {
125128
} = useAuth();
126129
const notify = useNotify();
127130
const { data: settings } = useSettings();
128-
const hasOidc = settings?.auth_methods?.includes("oidc");
131+
const hasOidc = settings?.auth_methods?.includes(AUTH_METHOD.OIDC);
129132
const hasCertificate = settings?.client_certificate;
130133
setFavicon();
131134
setTitle();
@@ -145,10 +148,15 @@ const App: FC = () => {
145148
const logoutAction = [
146149
{
147150
label: "Logout",
148-
onClick: () => (window.location.href = `${ROOT_PATH}/oidc/logout`),
151+
onClick: () => {
152+
if (isBearerAuthError(authError)) {
153+
logoutBearerToken();
154+
} else {
155+
logoutOidc();
156+
}
157+
},
149158
},
150159
];
151-
152160
notify.failure(title, authError, null, logoutAction);
153161
}
154162

@@ -160,7 +168,7 @@ const App: FC = () => {
160168
}
161169

162170
if (!isAuthenticated && hasOidc != undefined && hasCertificate != undefined) {
163-
logout(hasOidc, hasCertificate);
171+
logoutOidc();
164172
}
165173

166174
if (
@@ -560,6 +568,10 @@ const App: FC = () => {
560568
path={`${ROOT_PATH}/ui/login/certificate-add`}
561569
element={<CertificateAdd />}
562570
/>
571+
<Route
572+
path={`${ROOT_PATH}/ui/authentication-setup`}
573+
element={<AuthenticationSetup />}
574+
/>
563575
<Route path="*" element={<NoMatch />} />
564576
</Routes>
565577
</Suspense>

src/api/auth-identities.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const deleteIdentities = async (
9292
export const createFineGrainedTlsIdentity = async (
9393
name: string,
9494
groups: string[],
95+
hasToken = true,
9596
): Promise<TlsIdentityTokenDetail> => {
9697
return fetch(`${ROOT_PATH}/1.0/auth/identities/tls`, {
9798
method: "POST",
@@ -101,7 +102,7 @@ export const createFineGrainedTlsIdentity = async (
101102
body: JSON.stringify({
102103
name: name,
103104
groups: groups,
104-
token: true,
105+
token: hasToken,
105106
}),
106107
})
107108
.then(handleResponse)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { FC } from "react";
2+
import { Icon, Card } from "@canonical/react-components";
3+
import { Link } from "react-router-dom";
4+
import { ROOT_PATH } from "util/rootPath";
5+
import { AUTH_METHOD } from "util/authentication";
6+
import { useSettings } from "context/useSettings";
7+
import DocLink from "components/DocLink";
8+
9+
const AuthenticationOptions: FC = () => {
10+
const { data: settings } = useSettings();
11+
const hasOidc = settings?.auth_methods?.includes(AUTH_METHOD.OIDC);
12+
13+
return (
14+
<div className="auth-options-container">
15+
<Card className="auth-option-card u-flex-column">
16+
<Icon name="security" className="auth-option-icon u-hide--small" />
17+
<h2 className="p-heading--4">SSO</h2>
18+
<ul className="auth-option-pros-cons-list">
19+
<li>Centralized access control for teams</li>
20+
<li>Requires an external identity provider</li>
21+
</ul>
22+
23+
{hasOidc && (
24+
<a
25+
className="p-button--positive auth-option-link"
26+
href={`${ROOT_PATH}/oidc/login`}
27+
>
28+
Login with SSO
29+
</a>
30+
)}
31+
{!hasOidc && (
32+
<DocLink
33+
docPath="/howto/oidc"
34+
className="p-button--positive auth-option-link"
35+
>
36+
Set up SSO login
37+
</DocLink>
38+
)}
39+
</Card>
40+
41+
<Card className="auth-option-card u-flex-column">
42+
<Icon name="certificate" className="auth-option-icon u-hide--small" />
43+
<h2 className="p-heading--4">TLS</h2>
44+
45+
<ul className="auth-option-pros-cons-list">
46+
<li>Quick setup with a browser certificate</li>
47+
<li>No external identity provider required</li>
48+
</ul>
49+
<Link
50+
className="p-button auth-option-link"
51+
to={`${ROOT_PATH}/ui/login/certificate-generate`}
52+
>
53+
Set up TLS login
54+
</Link>
55+
</Card>
56+
</div>
57+
);
58+
};
59+
60+
export default AuthenticationOptions;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { FC } from "react";
2+
import { Step, Stepper } from "@canonical/react-components";
3+
import { useNavigate } from "react-router-dom";
4+
import { ROOT_PATH } from "util/rootPath";
5+
6+
interface Props {
7+
variant?: "horizontal" | "vertical";
8+
step2Name?: string;
9+
}
10+
11+
const AuthenticationTlsStepper: FC<Props> = ({
12+
variant,
13+
step2Name = "Identity trust token",
14+
}) => {
15+
const navigate = useNavigate();
16+
17+
return (
18+
<Stepper
19+
variant={variant}
20+
steps={[
21+
<Step
22+
key="Step 1"
23+
handleClick={() => {
24+
navigate(`${ROOT_PATH}/ui/login/certificate-generate`);
25+
}}
26+
index={1}
27+
title="Browser certificate"
28+
hasProgressLine={false}
29+
enabled
30+
iconName="number"
31+
selected={location.pathname.includes("certificate-generate")}
32+
iconClassName="stepper-icon"
33+
/>,
34+
<Step
35+
key="Step 2"
36+
handleClick={() => {
37+
navigate(`${ROOT_PATH}/ui/login/certificate-add`);
38+
}}
39+
index={2}
40+
title={step2Name}
41+
hasProgressLine={false}
42+
enabled
43+
iconName="number"
44+
selected={location.pathname.includes("certificate-add")}
45+
/>,
46+
]}
47+
/>
48+
);
49+
};
50+
51+
export default AuthenticationTlsStepper;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Link } from "react-router-dom";
2+
import { Icon } from "@canonical/react-components";
3+
import { getExpiryMessage } from "util/seconds";
4+
import { useSecondsLeft } from "context/useSecondsLeft";
5+
import { useAuth } from "context/auth";
6+
import { ROOT_PATH } from "util/rootPath";
7+
8+
const BearerTokenWarning: React.FC = () => {
9+
const { authExpiresAt } = useAuth();
10+
const secondsLeft = useSecondsLeft(authExpiresAt);
11+
const expiryMessage = getExpiryMessage(secondsLeft);
12+
13+
return (
14+
<div>
15+
<Icon name="warning" />
16+
{expiryMessage && (
17+
<span className="u-margin-left--small u-hide--medium u-hide--small">
18+
{expiryMessage}
19+
</span>
20+
)}
21+
<Link
22+
to={`${ROOT_PATH}/ui/authentication-setup`}
23+
className="u-text--link u-margin-left--small"
24+
>
25+
Set up permanent access
26+
</Link>
27+
</div>
28+
);
29+
};
30+
31+
export default BearerTokenWarning;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { FC } from "react";
2+
import { Notification } from "@canonical/react-components";
3+
import { useSettings } from "context/useSettings";
4+
import { useNavigate } from "react-router-dom";
5+
import { ROOT_PATH } from "util/rootPath";
6+
import { useAuth } from "context/auth";
7+
import { AUTH_METHOD } from "util/authentication";
8+
9+
const CertificateAddNotifications: FC = () => {
10+
const { data: settings } = useSettings();
11+
const { authMethod } = useAuth();
12+
const hasCertificate = settings?.client_certificate;
13+
const isBearerToken = authMethod === AUTH_METHOD.BEARER;
14+
const navigate = useNavigate();
15+
16+
return (
17+
<>
18+
{!isBearerToken && (
19+
<Notification title="Identity trust token" severity="information">
20+
In order for your browser certificate to be added to the
21+
server&rsquo;s trust store, you must present an identity trust token
22+
generated by the server.
23+
</Notification>
24+
)}
25+
{isBearerToken && (
26+
<Notification title="TLS identity" severity="information">
27+
Confirm the name and auth groups for your permanent access.
28+
</Notification>
29+
)}
30+
{hasCertificate === false && (
31+
<Notification
32+
severity="caution"
33+
title="Missing client certificate"
34+
actions={[
35+
{
36+
label: "Go back to step 1",
37+
onClick: () => {
38+
navigate(`${ROOT_PATH}/ui/login/certificate-generate`);
39+
},
40+
},
41+
]}
42+
>
43+
You are missing an installed client certificate. You may not be able
44+
to authenticate.
45+
</Notification>
46+
)}
47+
</>
48+
);
49+
};
50+
51+
export default CertificateAddNotifications;

src/components/IdentityResource.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FC } from "react";
22
import type { LxdIdentity } from "types/permissions";
33
import ResourceLabel from "./ResourceLabel";
4+
import { AUTH_METHOD } from "util/authentication";
45

56
interface Props {
67
identity: LxdIdentity;
@@ -10,7 +11,9 @@ interface Props {
1011

1112
const IdentityResource: FC<Props> = ({ identity, truncate, bold }) => {
1213
const identityIconType =
13-
identity.authentication_method == "tls" ? "certificate" : "oidc-identity";
14+
identity.authentication_method == AUTH_METHOD.TLS
15+
? "certificate"
16+
: "oidc-identity";
1417

1518
return (
1619
<ResourceLabel

0 commit comments

Comments
 (0)