mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: add support for OIDC logouts
Closes HP-407.
This commit is contained in:
@@ -7,15 +7,35 @@ export async function loader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||||
|
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
|
||||||
try {
|
try {
|
||||||
await context.auth.require(request);
|
principal = await context.auth.require(request);
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/login");
|
return redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
// When API key is disabled, we need to explicitly redirect
|
// When API key is disabled, we need to explicitly redirect
|
||||||
// with a logout state to prevent auto login again.
|
// with a logout state to prevent auto login again.
|
||||||
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||||
|
|
||||||
|
// For OIDC sessions, redirect to the provider's RP-initiated logout
|
||||||
|
// endpoint when explicitly enabled, so the upstream IdP session is also
|
||||||
|
// ended. Disabled by default because the post_logout_redirect_uri must be
|
||||||
|
// pre-registered on the IdP — turning this on without registering it would
|
||||||
|
// strand users on the IdP's error page.
|
||||||
|
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
|
||||||
|
const status = context.oidc.service.status();
|
||||||
|
if (status.state !== "ready") {
|
||||||
|
// Trigger discovery if it hasn't happened yet so we can find the
|
||||||
|
// end_session_endpoint without forcing a re-login.
|
||||||
|
await context.oidc.service.discover();
|
||||||
|
}
|
||||||
|
|
||||||
|
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
|
||||||
|
if (endSessionUrl) {
|
||||||
|
url = endSessionUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect(url, {
|
return redirect(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
||||||
|
// we'd be storing a credential we never use.
|
||||||
|
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
|
||||||
|
|
||||||
return redirect("/", {
|
return redirect("/", {
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
"Set-Cookie": await context.auth.createOidcSession(
|
||||||
name: identity.name,
|
userId,
|
||||||
email: identity.email,
|
{
|
||||||
username: identity.username,
|
name: identity.name,
|
||||||
}),
|
email: identity.email,
|
||||||
|
username: identity.username,
|
||||||
|
},
|
||||||
|
{ idToken },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ const oidcConfig = type({
|
|||||||
authorization_endpoint: "string.url?",
|
authorization_endpoint: "string.url?",
|
||||||
token_endpoint: "string.url?",
|
token_endpoint: "string.url?",
|
||||||
userinfo_endpoint: "string.url?",
|
userinfo_endpoint: "string.url?",
|
||||||
|
end_session_endpoint: "string.url?",
|
||||||
|
post_logout_redirect_uri: "string.url?",
|
||||||
|
use_end_session: "boolean = false",
|
||||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||||
|
|
||||||
// Old/deprecated options
|
// Old/deprecated options
|
||||||
@@ -147,6 +150,9 @@ const partialOidcConfig = type({
|
|||||||
authorization_endpoint: "string.url?",
|
authorization_endpoint: "string.url?",
|
||||||
token_endpoint: "string.url?",
|
token_endpoint: "string.url?",
|
||||||
userinfo_endpoint: "string.url?",
|
userinfo_endpoint: "string.url?",
|
||||||
|
end_session_endpoint: "string.url?",
|
||||||
|
post_logout_redirect_uri: "string.url?",
|
||||||
|
use_end_session: "boolean?",
|
||||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||||
|
|
||||||
// Old/deprecated options
|
// Old/deprecated options
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
|
|||||||
user_id: text("user_id"),
|
user_id: text("user_id"),
|
||||||
api_key_hash: text("api_key_hash"),
|
api_key_hash: text("api_key_hash"),
|
||||||
api_key_display: text("api_key_display"),
|
api_key_display: text("api_key_display"),
|
||||||
|
oidc_id_token: text("oidc_id_token"),
|
||||||
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
|
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const appLoadContext = {
|
|||||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||||
tokenEndpoint: config.oidc.token_endpoint,
|
tokenEndpoint: config.oidc.token_endpoint,
|
||||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||||
|
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||||
tokenEndpointAuthMethod:
|
tokenEndpointAuthMethod:
|
||||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||||
? undefined
|
? undefined
|
||||||
@@ -119,8 +120,10 @@ const appLoadContext = {
|
|||||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||||
extraParams: config.oidc.extra_params,
|
extraParams: config.oidc.extra_params,
|
||||||
profilePictureSource: config.oidc.profile_picture_source,
|
profilePictureSource: config.oidc.profile_picture_source,
|
||||||
|
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||||
}),
|
}),
|
||||||
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
||||||
|
useEndSession: config.oidc.use_end_session,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface OidcConfig {
|
|||||||
authorizationEndpoint?: string;
|
authorizationEndpoint?: string;
|
||||||
tokenEndpoint?: string;
|
tokenEndpoint?: string;
|
||||||
userinfoEndpoint?: string;
|
userinfoEndpoint?: string;
|
||||||
|
endSessionEndpoint?: string;
|
||||||
jwksUri?: string;
|
jwksUri?: string;
|
||||||
|
|
||||||
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
|
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
|
||||||
@@ -25,6 +26,7 @@ export interface OidcConfig {
|
|||||||
allowWeakRsaKeys?: boolean;
|
allowWeakRsaKeys?: boolean;
|
||||||
extraParams?: Record<string, string>;
|
extraParams?: Record<string, string>;
|
||||||
profilePictureSource?: "oidc" | "gravatar";
|
profilePictureSource?: "oidc" | "gravatar";
|
||||||
|
postLogoutRedirectUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedEndpoints {
|
export interface ResolvedEndpoints {
|
||||||
@@ -49,6 +51,7 @@ export interface OidcIdentity {
|
|||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OidcErrorCode =
|
export type OidcErrorCode =
|
||||||
@@ -89,6 +92,8 @@ export interface OidcService {
|
|||||||
flowState: OidcFlowState,
|
flowState: OidcFlowState,
|
||||||
): Promise<Result<OidcIdentity, OidcError>>;
|
): Promise<Result<OidcIdentity, OidcError>>;
|
||||||
|
|
||||||
|
buildEndSessionUrl(idToken?: string): string | undefined;
|
||||||
|
|
||||||
invalidate(): void;
|
invalidate(): void;
|
||||||
reload(config: OidcConfig): void;
|
reload(config: OidcConfig): void;
|
||||||
}
|
}
|
||||||
@@ -173,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
tokenEndpoint: config.tokenEndpoint!,
|
tokenEndpoint: config.tokenEndpoint!,
|
||||||
jwksUri: config.jwksUri!,
|
jwksUri: config.jwksUri!,
|
||||||
userinfoEndpoint: config.userinfoEndpoint,
|
userinfoEndpoint: config.userinfoEndpoint,
|
||||||
|
endSessionEndpoint: config.endSessionEndpoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
lastError = undefined;
|
lastError = undefined;
|
||||||
@@ -247,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
|
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
|
||||||
const userinfoEndpoint =
|
const userinfoEndpoint =
|
||||||
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
|
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
|
||||||
const endSessionEndpoint = metadata.end_session_endpoint as string | undefined;
|
const endSessionEndpoint =
|
||||||
|
config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined);
|
||||||
|
|
||||||
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
|
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
@@ -390,7 +397,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(buildIdentity(enriched));
|
return ok(buildIdentity(enriched, tokens.id_token));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeCode(
|
async function exchangeCode(
|
||||||
@@ -738,7 +745,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIdentity(claims: OidcClaims): OidcIdentity {
|
function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity {
|
||||||
const subject = resolveSubject(claims);
|
const subject = resolveSubject(claims);
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
throw new Error("OIDC subject was not resolved before identity construction");
|
throw new Error("OIDC subject was not resolved before identity construction");
|
||||||
@@ -769,9 +776,29 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
username,
|
username,
|
||||||
email: claims.email,
|
email: claims.email,
|
||||||
picture,
|
picture,
|
||||||
|
idToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEndSessionUrl(idToken?: string): string | undefined {
|
||||||
|
if (!endpoints?.endSessionEndpoint) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (idToken) {
|
||||||
|
params.set("id_token_hint", idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set("client_id", config.clientId);
|
||||||
|
|
||||||
|
const postLogoutRedirectUri =
|
||||||
|
config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href;
|
||||||
|
params.set("post_logout_redirect_uri", postLogoutRedirectUri);
|
||||||
|
|
||||||
|
return `${endpoints.endSessionEndpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function invalidate(): void {
|
function invalidate(): void {
|
||||||
endpoints = undefined;
|
endpoints = undefined;
|
||||||
lastError = undefined;
|
lastError = undefined;
|
||||||
@@ -803,7 +830,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status, discover, startFlow, handleCallback, invalidate, reload };
|
return {
|
||||||
|
status,
|
||||||
|
discover,
|
||||||
|
startFlow,
|
||||||
|
handleCallback,
|
||||||
|
buildEndSessionUrl,
|
||||||
|
invalidate,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
|
|
||||||
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
|
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
|
||||||
if (!currentConfig.allowWeakRsaKeys) {
|
if (!currentConfig.allowWeakRsaKeys) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type Principal =
|
|||||||
| {
|
| {
|
||||||
kind: "oidc";
|
kind: "oidc";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
idToken?: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -64,7 +65,7 @@ export interface AuthService {
|
|||||||
createOidcSession(
|
createOidcSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
profile: NonNullable<CookiePayload["profile"]>,
|
profile: NonNullable<CookiePayload["profile"]>,
|
||||||
maxAge?: number,
|
options?: { idToken?: string; maxAge?: number },
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
|
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
|
||||||
@@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
return {
|
return {
|
||||||
kind: "oidc",
|
kind: "oidc",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
idToken: session.oidc_id_token ?? undefined,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
subject: user.sub,
|
subject: user.sub,
|
||||||
@@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|||||||
async function createOidcSession(
|
async function createOidcSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
profile: NonNullable<CookiePayload["profile"]>,
|
profile: NonNullable<CookiePayload["profile"]>,
|
||||||
maxAge = opts.cookie.maxAge,
|
options?: { idToken?: string; maxAge?: number },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const maxAge = options?.maxAge ?? opts.cookie.maxAge;
|
||||||
const sid = ulid();
|
const sid = ulid();
|
||||||
await opts.db.insert(authSessions).values({
|
await opts.db.insert(authSessions).values({
|
||||||
id: sid,
|
id: sid,
|
||||||
kind: "oidc",
|
kind: "oidc",
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
oidc_id_token: options?.idToken,
|
||||||
expires_at: new Date(Date.now() + maxAge * 1000),
|
expires_at: new Date(Date.now() + maxAge * 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,25 @@ integration:
|
|||||||
# token_endpoint: ""
|
# token_endpoint: ""
|
||||||
# userinfo_endpoint: ""
|
# userinfo_endpoint: ""
|
||||||
|
|
||||||
|
# RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
|
||||||
|
# When true, /logout redirects the user to the IdP's end_session_endpoint
|
||||||
|
# (auto-discovered or set manually below) so the upstream session is ended too.
|
||||||
|
#
|
||||||
|
# Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered
|
||||||
|
# in your OIDC client configuration on the IdP. If it isn't, users will land
|
||||||
|
# on the provider's error page after logout.
|
||||||
|
# use_end_session: false
|
||||||
|
|
||||||
|
# Optional. Override the auto-discovered end_session_endpoint, or supply one
|
||||||
|
# if your provider does not advertise it via discovery.
|
||||||
|
# end_session_endpoint: ""
|
||||||
|
|
||||||
|
# Where the identity provider should redirect after RP-initiated logout.
|
||||||
|
# Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered
|
||||||
|
# in the OIDC client configuration. If unset, Headplane defaults to its own
|
||||||
|
# `<server.base_url>/admin/login?s=logout` page.
|
||||||
|
# post_logout_redirect_uri: ""
|
||||||
|
|
||||||
# The authentication method to use when communicating with the token endpoint.
|
# The authentication method to use when communicating with the token endpoint.
|
||||||
# This is fully optional and Headplane will attempt to auto-detect the best
|
# This is fully optional and Headplane will attempt to auto-detect the best
|
||||||
# method and fall back to `client_secret_basic` if unsure.
|
# method and fall back to `client_secret_basic` if unsure.
|
||||||
|
|||||||
@@ -235,6 +235,52 @@ When a new OIDC user signs in for the first time, they go through a brief
|
|||||||
onboarding flow that helps them connect their first device to the Tailnet. This
|
onboarding flow that helps them connect their first device to the Tailnet. This
|
||||||
flow can be skipped. Once completed, users are taken to the main dashboard.
|
flow can be skipped. Once completed, users are taken to the main dashboard.
|
||||||
|
|
||||||
|
## Single Logout (RP-Initiated Logout)
|
||||||
|
|
||||||
|
Headplane supports
|
||||||
|
[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
|
||||||
|
When enabled, clicking "Log Out" in the UI from an OIDC-backed session will:
|
||||||
|
|
||||||
|
1. Destroy the local Headplane session.
|
||||||
|
2. Redirect the browser to the identity provider's `end_session_endpoint`.
|
||||||
|
3. Pass along the original `id_token` as `id_token_hint`, plus a
|
||||||
|
`post_logout_redirect_uri` so the IdP can return the user to Headplane after
|
||||||
|
it has cleared its own session.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
This feature is **disabled by default** because the `post_logout_redirect_uri`
|
||||||
|
must be pre-registered in your OIDC client on the IdP. Enabling it without that
|
||||||
|
registration will land users on the provider's error page after logout.
|
||||||
|
|
||||||
|
To enable it, set `oidc.use_end_session: true`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
oidc:
|
||||||
|
# Required: opt in to RP-initiated logout
|
||||||
|
use_end_session: true
|
||||||
|
|
||||||
|
# Optional: override the auto-discovered end_session_endpoint, or set it
|
||||||
|
# manually if your provider does not expose it via discovery.
|
||||||
|
# end_session_endpoint: "https://idp.example.com/realms/main/protocol/openid-connect/logout"
|
||||||
|
|
||||||
|
# Optional. Defaults to `<server.base_url>/admin/login?s=logout`.
|
||||||
|
# post_logout_redirect_uri: "https://headplane.example.com/admin/login?s=logout"
|
||||||
|
```
|
||||||
|
|
||||||
|
If your provider exposes `end_session_endpoint` in its discovery document
|
||||||
|
(Keycloak, Authentik, Auth0, Azure AD, …) Headplane picks it up automatically
|
||||||
|
once `use_end_session` is `true`.
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
Make sure the redirect URI you supply (or the default one Headplane builds) is
|
||||||
|
listed under the post-logout / valid redirect URIs in your IdP's client
|
||||||
|
configuration, otherwise the provider will refuse to redirect back.
|
||||||
|
:::
|
||||||
|
|
||||||
|
When `use_end_session` is `false` (the default), Headplane simply destroys its
|
||||||
|
own session and returns the user to the login page.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `auth_sessions` ADD `oidc_id_token` text;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "f0bdd789-6848-40b3-a8b6-99b4d1f6ef07",
|
||||||
|
"prevIds": ["dd3ce0c0-106f-4628-8c36-bdf9747e5f41"],
|
||||||
|
"ddl": [
|
||||||
|
{
|
||||||
|
"name": "auth_sessions",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "host_info",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "users",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "kind",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "api_key_hash",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "api_key_display",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "oidc_id_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "auth_sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "host_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "host_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "payload",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "host_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "host_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "sub",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "picture",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'member'",
|
||||||
|
"generated": null,
|
||||||
|
"name": "role",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "headscale_user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "last_login_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "0",
|
||||||
|
"generated": null,
|
||||||
|
"name": "caps",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": ["id"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "auth_sessions_pk",
|
||||||
|
"table": "auth_sessions",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": ["host_id"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "host_info_pk",
|
||||||
|
"table": "host_info",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": ["id"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_pk",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": ["sub"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_sub_unique",
|
||||||
|
"entityType": "uniques",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": ["headscale_user_id"],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_headscale_user_id_unique",
|
||||||
|
"entityType": "uniques",
|
||||||
|
"table": "users"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": []
|
||||||
|
}
|
||||||
@@ -194,7 +194,7 @@ describe("session round-trip", () => {
|
|||||||
test("expired session throws", async () => {
|
test("expired session throws", async () => {
|
||||||
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
||||||
|
|
||||||
const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, -1);
|
const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, { maxAge: -1 });
|
||||||
|
|
||||||
const cookieValue = cookieHeader.split(";")[0];
|
const cookieValue = cookieHeader.split(";")[0];
|
||||||
const request = new Request("http://localhost/test", {
|
const request = new Request("http://localhost/test", {
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ describe("handleCallback", () => {
|
|||||||
expect(result.value.name).toBe("Test User");
|
expect(result.value.name).toBe("Test User");
|
||||||
expect(result.value.email).toBe("test@example.com");
|
expect(result.value.email).toBe("test@example.com");
|
||||||
expect(result.value.username).toBe("testuser");
|
expect(result.value.username).toBe("testuser");
|
||||||
|
expect(result.value.idToken).toBe(idToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("state mismatch returns error", async () => {
|
test("state mismatch returns error", async () => {
|
||||||
@@ -1126,3 +1127,80 @@ describe("path-based issuers", () => {
|
|||||||
pathServer.close();
|
pathServer.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildEndSessionUrl", () => {
|
||||||
|
test("returns undefined before discovery", () => {
|
||||||
|
const svc = createOidcService(testConfig());
|
||||||
|
expect(svc.buildEndSessionUrl("token")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined when provider has no end_session_endpoint", async () => {
|
||||||
|
const svc = createOidcService(
|
||||||
|
testConfig({
|
||||||
|
authorizationEndpoint: `${baseUrl}/authorize`,
|
||||||
|
tokenEndpoint: `${baseUrl}/token`,
|
||||||
|
jwksUri: `${baseUrl}/jwks`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.discover();
|
||||||
|
expect(svc.buildEndSessionUrl("token")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds RP-initiated logout URL after discovery", async () => {
|
||||||
|
const svc = createOidcService(testConfig());
|
||||||
|
await svc.discover();
|
||||||
|
|
||||||
|
const url = svc.buildEndSessionUrl("the-id-token");
|
||||||
|
expect(url).toBeDefined();
|
||||||
|
|
||||||
|
const parsed = new URL(url!);
|
||||||
|
expect(`${parsed.protocol}//${parsed.host}${parsed.pathname}`).toBe(`${baseUrl}/logout`);
|
||||||
|
expect(parsed.searchParams.get("id_token_hint")).toBe("the-id-token");
|
||||||
|
expect(parsed.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||||
|
expect(parsed.searchParams.get("post_logout_redirect_uri")).toBe(
|
||||||
|
"https://headplane.example.com/admin/login?s=logout",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits id_token_hint when not provided", async () => {
|
||||||
|
const svc = createOidcService(testConfig());
|
||||||
|
await svc.discover();
|
||||||
|
|
||||||
|
const url = svc.buildEndSessionUrl();
|
||||||
|
expect(url).toBeDefined();
|
||||||
|
|
||||||
|
const parsed = new URL(url!);
|
||||||
|
expect(parsed.searchParams.has("id_token_hint")).toBe(false);
|
||||||
|
expect(parsed.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses configured post_logout_redirect_uri override", async () => {
|
||||||
|
const svc = createOidcService(
|
||||||
|
testConfig({ postLogoutRedirectUri: "https://example.com/post-logout" }),
|
||||||
|
);
|
||||||
|
await svc.discover();
|
||||||
|
|
||||||
|
const url = svc.buildEndSessionUrl("the-id-token");
|
||||||
|
const parsed = new URL(url!);
|
||||||
|
expect(parsed.searchParams.get("post_logout_redirect_uri")).toBe(
|
||||||
|
"https://example.com/post-logout",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("honours manually configured end_session_endpoint", async () => {
|
||||||
|
const svc = createOidcService(
|
||||||
|
testConfig({
|
||||||
|
authorizationEndpoint: `${baseUrl}/authorize`,
|
||||||
|
tokenEndpoint: `${baseUrl}/token`,
|
||||||
|
jwksUri: `${baseUrl}/jwks`,
|
||||||
|
endSessionEndpoint: "https://provider.example.com/manual-logout",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.discover();
|
||||||
|
const url = svc.buildEndSessionUrl("the-id-token");
|
||||||
|
expect(url).toBeDefined();
|
||||||
|
expect(url!.startsWith("https://provider.example.com/manual-logout?")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user