Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions app/routes/auth/oidc-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
claims.sub,
);

// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
Expand All @@ -87,15 +84,30 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.from(users)
.where(eq(users.caps, Roles.owner));

// Match OIDC subject to Headscale user providerId
let headscaleUserId: string | undefined;
if (context.config.oidc?.integrate_headscale) {
headscaleUserId = await findHeadscaleUser(context, oidcConnector.apiKey, claims.sub);
}

await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
headscale_user_id: headscaleUserId,
})
.onConflictDoNothing();

// Update existing user with Headscale link if not set
if (headscaleUserId) {
await context.db
.update(users)
.set({ headscale_user_id: headscaleUserId })
.where(eq(users.sub, claims.sub));
}

return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
Expand Down Expand Up @@ -153,3 +165,26 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/login?s=error_auth_failed");
}
}

async function findHeadscaleUser(
context: Route.LoaderArgs["context"],
apiKey: string,
subject: string,
): Promise<string | undefined> {
try {
const api = context.hsApi.getRuntimeClient(apiKey);
const hsUsers = await api.getUsers();

for (const user of hsUsers) {
// providerId format is "oidc/subject123"
const userSubject = user.providerId?.split("/").pop();
if (userSubject === subject) {
log.info("auth", "Linked to Headscale user %s", user.id);
return user.id;
}
}
} catch (err) {
log.debug("auth", "Failed to query Headscale users: %o", err);
}
return undefined;
}
2 changes: 2 additions & 0 deletions app/server/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const oidcConfig = type({
client_secret: "string",
headscale_api_key: "string",
use_pkce: "boolean = false",
integrate_headscale: "boolean = false",
redirect_uri: type("string.url")
.pipe((value, ctx) => {
log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString);
Expand Down Expand Up @@ -111,6 +112,7 @@ const partialOidcConfig = type({
client_secret: "string?",
use_pkce: "boolean?",
headscale_api_key: "string?",
integrate_headscale: "boolean?",
redirect_uri: "string.url?",
disable_api_key_login: "boolean?",
scope: "string?",
Expand Down
32 changes: 16 additions & 16 deletions app/server/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { HostInfo } from '~/types';
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
auth_key: text('auth_key').primaryKey(),
node_key: text('node_key'),
import { HostInfo } from "~/types";

export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});

export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;

export const hostInfo = sqliteTable('host_info', {
host_id: text('host_id').primaryKey(),
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
() => new Date(),
),
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
});

export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert;

export const users = sqliteTable('users', {
id: text('id').primaryKey(),
sub: text('sub').notNull().unique(),
caps: integer('caps').notNull().default(0),
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
sub: text("sub").notNull().unique(),
caps: integer("caps").notNull().default(0),
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
headscale_user_id: text("headscale_user_id"),
});

export type User = typeof users.$inferSelect;
Expand Down
3 changes: 3 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ integration:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"

# Link OIDC users to Headscale users by matching the subject to providerId.
# integrate_headscale: false

# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
Expand Down
1 change: 1 addition & 0 deletions drizzle/0003_link_headscale_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN headscale_user_id TEXT;
126 changes: 126 additions & 0 deletions drizzle/meta/0003_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f8a2c1e4-3d7b-4a9e-b5c6-8e1f2a3b4c5d",
"prevId": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71",
"tables": {
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"host_info": {
"name": "host_info",
"columns": {
"host_id": {
"name": "host_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sub": {
"name": "sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"caps": {
"name": "caps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"onboarded": {
"name": "onboarded",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"headscale_user_id": {
"name": "headscale_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_sub_unique": {
"name": "users_sub_unique",
"columns": ["sub"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
57 changes: 32 additions & 25 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1750355487927,
"tag": "0000_spicy_bloodscream",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755554742267,
"tag": "0001_naive_lilith",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
}
]
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1750355487927,
"tag": "0000_spicy_bloodscream",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755554742267,
"tag": "0001_naive_lilith",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772190754000,
"tag": "0003_link_headscale_users",
"breakpoints": true
}
]
}
Loading