Skip to content

Commit a72b803

Browse files
warwickschroederWilliamBZA
authored andcommitted
Add AuthError handling and display component for authentication failures
1 parent 5fd0188 commit a72b803

10 files changed

Lines changed: 208 additions & 13 deletions

File tree

src/Frontend/src/AuthApp.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import { useAuthStore } from "@/stores/AuthStore";
66
import routeLinks from "@/router/routeLinks";
77
import LoadingSpinner from "@/components/LoadingSpinner.vue";
88
import App from "./App.vue";
9+
import AuthErrorScreen from "@/components/AuthErrorScreen.vue";
910
import logger from "@/logger";
1011
1112
const { authenticate } = useAuth();
1213
const authStore = useAuthStore();
13-
const { isAuthenticating, loading } = storeToRefs(authStore);
14+
const { isAuthenticating, loading, authError } = storeToRefs(authStore);
1415
1516
onMounted(async () => {
1617
try {
@@ -56,6 +57,7 @@ onMounted(async () => {
5657
<LoadingSpinner />
5758
<div class="loading-text">Authenticating...</div>
5859
</div>
60+
<AuthErrorScreen v-else-if="authError" :error="authError" />
5961
<App v-else />
6062
</template>
6163

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect, test, describe, render, screen } from "@component-test-utils";
2+
3+
import AuthErrorScreen from "./AuthErrorScreen.vue";
4+
5+
describe("AuthErrorScreen", () => {
6+
test("shows scope-specific guidance and the raw detail for invalid_scope", async () => {
7+
render(AuthErrorScreen, {
8+
props: {
9+
error: { code: "invalid_scope", description: "Invalid scopes: Pulse openid profile email offline_access" },
10+
},
11+
});
12+
13+
expect(await screen.findByText("Unable to sign in")).toBeVisible();
14+
expect(screen.getByText(/offline_access scope may be disabled/)).toBeVisible();
15+
expect(screen.getByText(/Invalid scopes: Pulse openid profile email offline_access/)).toBeVisible();
16+
});
17+
18+
test("shows a generic message and the raw detail for an error without a recognized code", async () => {
19+
render(AuthErrorScreen, {
20+
props: { error: { description: "Callback failed" } },
21+
});
22+
23+
expect(await screen.findByText(/Contact your administrator/)).toBeVisible();
24+
expect(screen.getByText(/Callback failed/)).toBeVisible();
25+
});
26+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import { computed } from "vue";
3+
import type { AuthError } from "@/types/auth";
4+
import { describeAuthError } from "@/composables/authError";
5+
6+
const props = defineProps<{
7+
error: AuthError;
8+
}>();
9+
10+
const display = computed(() => describeAuthError(props.error));
11+
</script>
12+
13+
<template>
14+
<div class="auth-error-container">
15+
<div class="auth-error-content">
16+
<h1 class="auth-error-title">{{ display.title }}</h1>
17+
<p class="auth-error-message">{{ display.message }}</p>
18+
<p class="auth-error-detail" role="status">Details: {{ props.error.description }}</p>
19+
</div>
20+
</div>
21+
</template>
22+
23+
<style scoped>
24+
/* Modeled on LoggedOutView.vue for a consistent full-screen auth surface. */
25+
.auth-error-container {
26+
display: flex;
27+
justify-content: center;
28+
align-items: center;
29+
min-height: 100vh;
30+
background-color: #f5f5f5;
31+
padding: 20px;
32+
}
33+
34+
.auth-error-content {
35+
text-align: center;
36+
background: white;
37+
padding: 60px 40px;
38+
border-radius: 8px;
39+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
40+
max-width: 500px;
41+
width: 100%;
42+
}
43+
44+
.auth-error-title {
45+
font-size: 24px;
46+
font-weight: 600;
47+
color: #333;
48+
margin-bottom: 16px;
49+
}
50+
51+
.auth-error-message {
52+
font-size: 16px;
53+
color: #666;
54+
margin-bottom: 24px;
55+
}
56+
57+
.auth-error-detail {
58+
font-size: 13px;
59+
color: #999;
60+
word-break: break-word;
61+
}
62+
</style>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, test, expect } from "vitest";
2+
import { describeAuthError } from "@/composables/authError";
3+
4+
describe("describeAuthError", () => {
5+
test("gives scope-specific guidance for invalid_scope", () => {
6+
const result = describeAuthError({
7+
code: "invalid_scope",
8+
description: "Invalid scopes: Pulse openid profile email offline_access",
9+
});
10+
expect(result.title).toBe("Unable to sign in");
11+
expect(result.message).toContain("offline_access");
12+
});
13+
14+
test("gives a generic message for an unrecognized code", () => {
15+
const result = describeAuthError({ code: "server_error", description: "server_error" });
16+
expect(result.message).toContain("Contact your administrator");
17+
});
18+
19+
test("gives a generic message when there is no code", () => {
20+
const result = describeAuthError({ description: "Callback failed" });
21+
expect(result.message).toContain("Contact your administrator");
22+
});
23+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { AuthError } from "@/types/auth";
2+
3+
export interface AuthErrorDisplay {
4+
title: string;
5+
message: string;
6+
}
7+
8+
/**
9+
* Maps a captured authentication failure to user-facing copy. Recognised OAuth error codes get
10+
* specific, actionable guidance; everything else — including local/callback exceptions with no
11+
* code — gets a generic message. The raw `error.description` is rendered separately by the
12+
* component, so it is not repeated here.
13+
*/
14+
export function describeAuthError(error: AuthError): AuthErrorDisplay {
15+
switch (error.code) {
16+
case "invalid_scope":
17+
return {
18+
title: "Unable to sign in",
19+
message: "Your identity provider rejected one or more of the requested scopes. The 'offline_access' scope may be disabled in your IdP, ask your administrator to enable it or update ServiceControl so ServicePulse does not request it.",
20+
};
21+
default:
22+
return {
23+
title: "Unable to sign in",
24+
message: "Something went wrong while signing you in. Contact your administrator if the problem continues.",
25+
};
26+
}
27+
}

src/Frontend/src/composables/useAuth.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function useAuth() {
2929
await userManager?.signinRedirect();
3030
} catch (error) {
3131
logger.error("Re-authentication after session loss failed:", error);
32-
authStore.setAuthError(error instanceof Error ? error.message : "Re-authentication after session loss failed");
32+
authStore.setAuthError({ description: error instanceof Error ? error.message : "Re-authentication after session loss failed" });
3333
} finally {
3434
authStore.setAuthenticating(false);
3535
}
@@ -116,17 +116,18 @@ export function useAuth() {
116116
errorMessage: error instanceof Error ? error.message : "Unknown error",
117117
errorStack: error instanceof Error ? error.stack : undefined,
118118
});
119-
authStore.setAuthError(error instanceof Error ? error.message : "Callback failed");
119+
authStore.setAuthError({ description: error instanceof Error ? error.message : "Callback failed" });
120120
// Don't continue - callback failed, user needs to try again
121121
return false;
122122
} finally {
123123
authStore.setAuthenticating(false);
124124
}
125125
} else if (hasError) {
126126
// OAuth error in callback
127+
const errorCode = params.get("error") ?? undefined;
127128
const errorDescription = params.get("error_description") || params.get("error");
128129
logger.error("OAuth error:", errorDescription);
129-
authStore.setAuthError(errorDescription || "Authentication failed");
130+
authStore.setAuthError({ code: errorCode, description: errorDescription || "Authentication failed" });
130131
return false;
131132
}
132133

@@ -144,7 +145,7 @@ export function useAuth() {
144145
} catch (error) {
145146
authStore.setAuthenticating(false);
146147
const errorMessage = error instanceof Error ? error.message : "Unknown authentication error";
147-
authStore.setAuthError(errorMessage);
148+
authStore.setAuthError({ description: errorMessage });
148149
logger.error("Authentication error:", error);
149150
throw error;
150151
}

src/Frontend/src/stores/AuthStore.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,21 @@ describe("AuthStore tests", () => {
1818
});
1919

2020
test("uses the composed scopes field from ServiceControl when present", async () => {
21+
// The store must pass `scopes` through untouched. This value deliberately differs from anything
22+
// derivable from api_scopes (a different scope, and no offline_access) so the test fails if the
23+
// store ever reverts to assembling the scope string itself instead of trusting ServiceControl.
2124
vi.spyOn(serviceControlClient, "fetchTypedFromServiceControl").mockResolvedValue([
2225
{} as Response,
2326
{
2427
...baseResponse,
25-
scopes: "api://test-audience/.default openid profile email",
28+
scopes: "api://servicecontrol/composed-by-servicecontrol openid profile email",
2629
},
2730
]);
2831

2932
const store = useAuthStore();
3033
await store.refresh();
3134

32-
expect(store.authConfig?.scope).toBe("api://test-audience/.default openid profile email");
35+
expect(store.authConfig?.scope).toBe("api://servicecontrol/composed-by-servicecontrol openid profile email");
3336
});
3437

3538
test("falls back to assembling scopes from api_scopes when talking to an older ServiceControl", async () => {

src/Frontend/src/stores/AuthStore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { acceptHMRUpdate, defineStore } from "pinia";
22
import logger from "@/logger";
33
import { ref } from "vue";
4-
import type { AuthConfig } from "@/types/auth";
4+
import type { AuthConfig, AuthError } from "@/types/auth";
55
import { WebStorageStateStore } from "oidc-client-ts";
66
import routeLinks from "@/router/routeLinks";
77
import serviceControlClient from "@/components/serviceControlClient";
@@ -24,7 +24,7 @@ export const useAuthStore = defineStore("auth", () => {
2424
const token = ref<string | null>(null);
2525
const isAuthenticated = ref(false);
2626
const isAuthenticating = ref(false);
27-
const authError = ref<string | null>(null);
27+
const authError = ref<AuthError | null>(null);
2828
const authConfig = ref<AuthConfig | null>(null);
2929
const authEnabled = ref(false);
3030
// undefined means ServiceControl didn't report this field (older version) — treat as enabled.
@@ -110,7 +110,7 @@ export const useAuthStore = defineStore("auth", () => {
110110
isAuthenticating.value = value;
111111
}
112112

113-
function setAuthError(error: string | null) {
113+
function setAuthError(error: AuthError | null) {
114114
authError.value = error;
115115
}
116116

src/Frontend/src/types/auth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,15 @@ import type { UserManagerSettings } from "oidc-client-ts";
55
* This provides type-safe configuration for any OIDC-compliant identity provider
66
*/
77
export type AuthConfig = UserManagerSettings;
8+
9+
/**
10+
* A captured authentication failure.
11+
* `code` is the OAuth error code from an identity-provider error redirect (e.g. "invalid_scope");
12+
* it is absent for local/callback exceptions that carry no OAuth code.
13+
* `description` is the human-readable detail (the IdP's error_description or an exception message)
14+
* and is always shown to the user.
15+
*/
16+
export interface AuthError {
17+
code?: string;
18+
description: string;
19+
}

src/Frontend/test/specs/authentication/auth-callback-error.spec.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe("FEATURE: OAuth Callback Error Handling (Scenario 16)", () => {
7070
});
7171

7272
// Verify the error message contains the description
73-
expect(authStore.authError).toContain("cancelled");
73+
expect(authStore.authError?.description).toContain("cancelled");
7474

7575
// User should not be authenticated
7676
expect(authStore.isAuthenticated).toBe(false);
@@ -107,7 +107,7 @@ describe("FEATURE: OAuth Callback Error Handling (Scenario 16)", () => {
107107
});
108108

109109
// Verify the error captures the description
110-
expect(authStore.authError).toContain("Missing");
110+
expect(authStore.authError?.description).toContain("Missing");
111111

112112
// User should not be authenticated
113113
expect(authStore.isAuthenticated).toBe(false);
@@ -144,12 +144,51 @@ describe("FEATURE: OAuth Callback Error Handling (Scenario 16)", () => {
144144
});
145145

146146
// When no description, the error code should be used
147-
expect(authStore.authError).toBe("server_error");
147+
expect(authStore.authError?.description).toBe("server_error");
148148

149149
// User should not be authenticated
150150
expect(authStore.isAuthenticated).toBe(false);
151151

152152
expect(logger.error).toHaveBeenCalledWith("OAuth error:", "server_error");
153153
});
154+
155+
test("EXAMPLE: invalid_scope error captures the OAuth error code", async ({ driver }) => {
156+
const mockSearch = "?error=invalid_scope&error_description=Invalid%20scopes%3A%20Pulse%20openid%20profile%20email%20offline_access";
157+
158+
const mockLocation = {
159+
...originalLocation,
160+
search: mockSearch,
161+
hash: "#/dashboard",
162+
href: `http://localhost:5173${mockSearch}#/dashboard`,
163+
};
164+
165+
Object.defineProperty(window, "location", {
166+
value: mockLocation,
167+
writable: true,
168+
configurable: true,
169+
});
170+
171+
await driver.setUp(precondition.serviceControlWithMonitoring);
172+
await driver.setUp(precondition.hasAuthenticationEnabled());
173+
174+
await driver.goTo("/dashboard");
175+
176+
const authStore = useAuthStore();
177+
178+
await waitFor(() => {
179+
expect(authStore.authError).toBeTruthy();
180+
});
181+
182+
expect(authStore.authError?.code).toBe("invalid_scope");
183+
expect(authStore.authError?.description).toContain("Invalid scopes");
184+
185+
expect(authStore.isAuthenticated).toBe(false);
186+
187+
Object.defineProperty(window, "location", {
188+
value: originalLocation,
189+
writable: true,
190+
configurable: true,
191+
});
192+
});
154193
});
155194
});

0 commit comments

Comments
 (0)