Skip to content

Commit

Permalink
Merge pull request #25 from keycloakify/authRequiredOnEveryPages
Browse files Browse the repository at this point in the history
Auth required on every pages
  • Loading branch information
garronej authored Jul 19, 2024
2 parents 5a8289f + ee329ae commit e4d9bfd
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 39 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oidc-spa",
"version": "4.8.1",
"version": "4.9.0",
"description": "Openidconnect client for Single Page Applications",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { createOidc } from "./oidc";
export type { Oidc } from "./oidc";
export type { Oidc, OidcInitializationError } from "./oidc";
export { decodeJwt } from "./tools/decodeJwt";
40 changes: 32 additions & 8 deletions src/mock/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,32 @@ import { createObjectThatThrowsIfAccessed } from "../tools/createObjectThatThrow
import { assert, type Equals } from "tsafe/assert";

export type ParamsOfCreateMockOidc<
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>,
IsAuthRequiredOnEveryPages extends boolean = false
> = {
isUserInitiallyLoggedIn: boolean;
mockedParams?: Partial<Oidc["params"]>;
mockedTokens?: Partial<Oidc.Tokens<DecodedIdToken>>;
publicUrl?: string;
isAuthRequiredOnEveryPages?: IsAuthRequiredOnEveryPages;
postLoginRedirectUrl?: string;
};

const urlParamName = "isUserLoggedIn";

export function createMockOidc<DecodedIdToken extends Record<string, unknown> = Record<string, unknown>>(
params: ParamsOfCreateMockOidc<DecodedIdToken>
): Oidc<DecodedIdToken> {
export function createMockOidc<
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>,
IsAuthRequiredOnEveryPages extends boolean = false
>(
params: ParamsOfCreateMockOidc<DecodedIdToken, IsAuthRequiredOnEveryPages>
): IsAuthRequiredOnEveryPages extends true ? Oidc.LoggedIn<DecodedIdToken> : Oidc<DecodedIdToken> {
const {
isUserInitiallyLoggedIn,
mockedParams = {},
mockedTokens = {},
publicUrl: publicUrl_params
publicUrl: publicUrl_params,
isAuthRequiredOnEveryPages = false,
postLoginRedirectUrl
} = params;

const isUserLoggedIn = (() => {
Expand Down Expand Up @@ -60,12 +68,19 @@ export function createMockOidc<DecodedIdToken extends Record<string, unknown> =
};

if (!isUserLoggedIn) {
return id<Oidc.NotLoggedIn>({
const oidc = id<Oidc.NotLoggedIn>({
...common,
"isUserLoggedIn": false,
"login": async () => {
"login": async ({ redirectUrl }) => {
const { newUrl } = addQueryParamToUrl({
"url": window.location.href,
"url": (() => {
if (redirectUrl === undefined) {
return window.location.href;
}
return redirectUrl.startsWith("/")
? `${window.location.origin}${redirectUrl}`
: redirectUrl;
})(),
"name": urlParamName,
"value": "true"
});
Expand All @@ -76,6 +91,15 @@ export function createMockOidc<DecodedIdToken extends Record<string, unknown> =
},
"initializationError": undefined
});
if (!isAuthRequiredOnEveryPages) {
oidc.login({
"redirectUrl": postLoginRedirectUrl,
"doesCurrentHrefRequiresAuth": true
});
assert(false);
}
// @ts-expect-error: We know what we are doing
return oidc;
}

return id<Oidc.LoggedIn<DecodedIdToken>>({
Expand Down
10 changes: 6 additions & 4 deletions src/mock/react.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createReactOidc_dependencyInjection } from "../react/react";
import { createOidcReactApi_dependencyInjection } from "../react/react";
import { createMockOidc, type ParamsOfCreateMockOidc } from "./oidc";

/** @see: https://docs.oidc-spa.dev/documentation/mock */
export function createMockReactOidc<
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>
>(params: ParamsOfCreateMockOidc<DecodedIdToken>) {
return createReactOidc_dependencyInjection(params, createMockOidc);
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>,
IsAuthRequiredOnEveryPages extends boolean = false
>(params: ParamsOfCreateMockOidc<DecodedIdToken, IsAuthRequiredOnEveryPages>) {
return createOidcReactApi_dependencyInjection(params, createMockOidc);
}
138 changes: 130 additions & 8 deletions src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class OidcInitializationError extends Error {
type: "misconfigured OIDC client";
clientId: string;
timeoutDelayMs: number;
publicUrl: string | undefined;
}
| {
type: "not in Web Origins";
Expand All @@ -100,6 +101,13 @@ export class OidcInitializationError extends Error {
| {
type: "silent-sso.html not reachable";
silentSsoHtmlUrl: string;
}
| {
type: "frame-ancestors none";
silentSso: {
hasDedicatedHtmlFile: boolean;
redirectUri: string;
};
};
}
| {
Expand All @@ -122,7 +130,9 @@ export class OidcInitializationError extends Error {
case "misconfigured OIDC client":
return [
`The OIDC client ${params.likelyCause.clientId} seems to be misconfigured on your OIDC server.`,
`If you are using Keycloak you likely need to add "${location.origin}/*" to the list of Valid Redirect URIs`,
`If you are using Keycloak you likely need to add "${
params.likelyCause.publicUrl ?? window.location.origin
}/*" to the list of Valid Redirect URIs`,
`in the ${params.likelyCause.clientId} client configuration.\n`,
`More info: https://docs.oidc-spa.dev/resources/usage-with-keycloak`,
`Silent SSO timed out after ${params.likelyCause.timeoutDelayMs}ms.`
Expand All @@ -139,6 +149,24 @@ export class OidcInitializationError extends Error {
`${params.likelyCause.silentSsoHtmlUrl} is not reachable. Make sure you've created the silent-sso.html file`,
`in your public directory. More info: https://docs.oidc-spa.dev/documentation/installation`
].join(" ");
case "frame-ancestors none":
return [
params.likelyCause.silentSso.hasDedicatedHtmlFile
? `The silent-sso.html file, `
: `The URI used for Silent SSO, `,
`${params.likelyCause.silentSso.redirectUri}, `,
"is served by your web server with the HTTP header `Content-Security-Policy: frame-ancestors none` in the response.\n",
"This header prevents the silent sign-in process from working.\n",
"To fix this issue, you should configure your web server to not send this header or to use `frame-ancestors self` instead of `frame-ancestors none`.\n",
"If you use Nginx, you can replace:\n",
`add_header Content-Security-Policy "frame-ancestors 'none'";\n`,
"with:\n",
`map $uri $add_content_security_policy {\n`,
` "~*silent-sso\.html$" "frame-ancestors 'self'";\n`,
` default "frame-ancestors 'none'";\n`,
`}\n`,
`add_header Content-Security-Policy $add_content_security_policy;\n`
].join(" ");
}
case "unknown":
return params.cause.message;
Expand All @@ -156,20 +184,35 @@ export class OidcInitializationError extends Error {
const paramsToRetrieveFromSuccessfulLogin = ["code", "state", "session_state", "iss"] as const;

export type ParamsOfCreateOidc<
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>,
IsAuthRequiredOnEveryPages extends boolean = false
> = {
issuerUri: string;
clientId: string;
clientSecret?: string;
/**
* Transform the url before redirecting to the login pages.
*/
transformUrlBeforeRedirect?: (url: string) => string;
/**
* Extra query params to be added on the login url.
* You can provide a function that returns those extra query params, it will be called
* when login() is called.
*
* Example: extraQueryParams: ()=> ({ ui_locales: "fr" })
*
* This parameter can also be passed to login() directly.
*/
extraQueryParams?: Record<string, string> | (() => Record<string, string>);
/**
* Where to redirect after successful login.
* Default: window.location.href (here)
*
* It does not need to include the origin, eg: "/dashboard"
*
* This parameter can also be passed to login() directly as `redirectUrl`.
*/
postLoginRedirectUrl?: string;
/**
* This parameter is used to let oidc-spa knows where to find the silent-sso.html file
* and also to know what is the root path of your application so it can redirect to it after logout.
Expand Down Expand Up @@ -198,6 +241,7 @@ export type ParamsOfCreateOidc<
__unsafe_ssoSessionIdleSeconds?: number;

autoLogoutParams?: Parameters<Oidc.LoggedIn<any>["logout"]>[0];
isAuthRequiredOnEveryPages?: IsAuthRequiredOnEveryPages;
};

let $isUserActive: StatefulObservable<boolean> | undefined = undefined;
Expand All @@ -207,8 +251,13 @@ const URL_real = window.URL;

/** @see: https://github.com/garronej/oidc-spa#option-1-usage-without-involving-the-ui-framework */
export async function createOidc<
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>
>(params: ParamsOfCreateOidc<DecodedIdToken>): Promise<Oidc<DecodedIdToken>> {
DecodedIdToken extends Record<string, unknown> = Record<string, unknown>,
IsAuthRequiredOnEveryPages extends boolean = false
>(
params: ParamsOfCreateOidc<DecodedIdToken, IsAuthRequiredOnEveryPages>
): Promise<
IsAuthRequiredOnEveryPages extends true ? Oidc.LoggedIn<DecodedIdToken> : Oidc<DecodedIdToken>
> {
const {
issuerUri,
clientId,
Expand All @@ -218,7 +267,9 @@ export async function createOidc<
publicUrl: publicUrl_params,
decodedIdTokenSchema,
__unsafe_ssoSessionIdleSeconds,
autoLogoutParams = { "redirectTo": "current page" }
autoLogoutParams = { "redirectTo": "current page" },
isAuthRequiredOnEveryPages = false,
postLoginRedirectUrl
} = params;

const getExtraQueryParams = (() => {
Expand Down Expand Up @@ -594,13 +645,18 @@ export async function createOidc<
})();

const timeout = setTimeout(async () => {
let dedicatedSilentSsoHtmlFileCsp: string | null | undefined = undefined;

silent_sso_html_unreachable: {
if (!silentSso.hasDedicatedHtmlFile) {
break silent_sso_html_unreachable;
}

const isSilentSsoHtmlReachable = await fetch(silentSso.redirectUri).then(
async response => {
dedicatedSilentSsoHtmlFileCsp =
response.headers.get("Content-Security-Policy");

const content = await response.text();

return (
Expand All @@ -627,6 +683,54 @@ export async function createOidc<
return;
}

frame_ancestors_none: {
const csp = await (async () => {
if (silentSso.hasDedicatedHtmlFile) {
assert(dedicatedSilentSsoHtmlFileCsp !== undefined);
return dedicatedSilentSsoHtmlFileCsp;
}

const csp = await fetch(silentSso.redirectUri).then(
response => response.headers.get("Content-Security-Policy"),
error => id<Error>(error)
);

if (csp instanceof Error) {
dLoginSuccessUrl.reject(
new Error(`Failed to fetch ${silentSso.redirectUri}: ${csp.message}`)
);
return new Promise<never>(() => {});
}

return csp;
})();

if (csp === null) {
break frame_ancestors_none;
}

const hasFrameAncestorsNone = csp
.replace(/"'/g, "")
.replace(/\s+/g, " ")
.toLowerCase()
.includes("frame-ancestors none");

if (!hasFrameAncestorsNone) {
break frame_ancestors_none;
}

dLoginSuccessUrl.reject(
new OidcInitializationError({
"type": "bad configuration",
"likelyCause": {
"type": "frame-ancestors none",
silentSso
}
})
);
return;
}

// Here we know that the server is not down and that the issuer_uri is correct
// otherwise we would have had a fetch error when loading the iframe.
// So this means that it's very likely a OIDC client misconfiguration.
Expand All @@ -638,7 +742,8 @@ export async function createOidc<
"likelyCause": {
"type": "misconfigured OIDC client",
clientId,
timeoutDelayMs
timeoutDelayMs,
publicUrl
}
})
);
Expand Down Expand Up @@ -820,13 +925,17 @@ export async function createOidc<
"cause": error
});

if (isAuthRequiredOnEveryPages) {
throw initializationError;
}

console.error(
`OIDC initialization error of type "${initializationError.type}": ${initializationError.message}`
);

startTrackingLastPublicRoute();

return id<Oidc.NotLoggedIn>({
const oidc = id<Oidc.NotLoggedIn>({
...common,
"isUserLoggedIn": false,
"login": async () => {
Expand All @@ -835,17 +944,30 @@ export async function createOidc<
},
initializationError
});

// @ts-expect-error: We know what we are doing.
return oidc;
}

if (resultOfLoginProcess === undefined) {
if (isAuthRequiredOnEveryPages) {
await login({
"doesCurrentHrefRequiresAuth": true,
"redirectUrl": postLoginRedirectUrl
});
}

startTrackingLastPublicRoute();

return id<Oidc.NotLoggedIn>({
const oidc = id<Oidc.NotLoggedIn>({
...common,
"isUserLoggedIn": false,
login,
"initializationError": undefined
});

// @ts-expect-error: We know what we are doing.
return oidc;
}

let currentTokens = resultOfLoginProcess.tokens;
Expand Down
Loading

0 comments on commit e4d9bfd

Please sign in to comment.