diff --git a/.env.example b/.env.example index 59d90f078..b6af211d6 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,29 @@ # Can be generated with e.g. pwgen -s 64 1 # Please provide a string of length 64+ characters # SESSION_SECRET= + +# Enable login with OAuth 2.0 +# OAUTH2_ENABLED: false +# OAUTH2_CLIENT_ID: ctfnote +# OAUTH2_CLIENT_SECRET: insecure_secret +# OAUTH2_SCOPE: openid profile groups + +# The attribute to use as login and username +# OAUTH2_USERNAME_ATTR: name + +# The attribute to use for determining the user's role +# The attribute can either be a string or an array of strings +# In case of an array, the highest role will be selected +# OAUTH2_ROLE_ATTR: groups + +# A mapping for the values of the attribute to roles in CTFNote +# roles: user_admin, user_manager, user_member, user_fried, user_guest, none (no access to CTFNote) +# OAUTH2_ROLE_MAPPING: '{"admin": "user_admin"}' + +# Either specify the discovery url or all other properties +# If a discovery url is provided, the other properties overwrite the values from the discovery +# OAUTH2_DISCOVERY_URL: https://example.com/.well-known/openid-configuration +# OAUTH2_ISSUER: https://example.com +# OAUTH2_AUTHORIZATION_ENDPOINT: https://example.com/api/oidc/authorization +# OAUTH2_TOKEN_ENDPOINT: https://example.com/api/oidc/token +# OAUTH2_USERINFO_ENDPOINT: https://example.com/api/oidc/userinfo diff --git a/api/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip b/api/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip new file mode 100644 index 000000000..3dd0ee495 Binary files /dev/null and b/api/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip differ diff --git a/api/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip b/api/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip new file mode 100644 index 000000000..23b7fb385 Binary files /dev/null and b/api/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip differ diff --git a/api/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip b/api/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip new file mode 100644 index 000000000..b4ef7a17e Binary files /dev/null and b/api/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip differ diff --git a/api/Dockerfile b/api/Dockerfile index 5cc0a8331..f226918c9 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,7 +2,7 @@ # Global args, set before the first FROM, shared by all stages ARG NODE_ENV="production" -ARG NODE_IMAGE="18.19.1-alpine" +ARG NODE_IMAGE="20.19.5-alpine" ################################################################################ # Build stage 1 - `yarn build` diff --git a/api/migrations/57-external-login.sql b/api/migrations/57-external-login.sql new file mode 100644 index 000000000..d7b7933f5 --- /dev/null +++ b/api/migrations/57-external-login.sql @@ -0,0 +1,25 @@ +-- Login with external identity and register/migrate user if not present/externally managed +CREATE FUNCTION ctfnote_private.login_with_extern("name" text, "role" ctfnote.role) +RETURNS ctfnote.jwt +AS $$ +DECLARE + log_user ctfnote_private.user; +BEGIN + INSERT INTO ctfnote_private.user ("login", "password", "role") + VALUES (login_with_extern.name, 'external', login_with_extern.role) + ON CONFLICT ("login") DO UPDATE + SET password = 'external', role = login_with_extern.role + RETURNING + * INTO log_user; + INSERT INTO ctfnote.profile ("id", "username") + VALUES (log_user.id, login_with_extern.name) + ON CONFLICT (id) DO UPDATE + SET username = login_with_extern.name; + RETURN (ctfnote_private.new_token (log_user.id))::ctfnote.jwt; +END; +$$ +LANGUAGE plpgsql +STRICT +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION ctfnote_private.login_with_extern TO user_anonymous; diff --git a/api/migrations/58-oauth2-login.sql b/api/migrations/58-oauth2-login.sql new file mode 100644 index 000000000..7104cbdb9 --- /dev/null +++ b/api/migrations/58-oauth2-login.sql @@ -0,0 +1,5 @@ +ALTER TABLE ctfnote.settings + ADD COLUMN "oauth2_enabled" boolean NOT NULL DEFAULT FALSE; + +GRANT SELECT ("oauth2_enabled") ON ctfnote.settings TO user_anonymous; +GRANT UPDATE ("oauth2_enabled") ON ctfnote.settings TO user_postgraphile; diff --git a/api/package.json b/api/package.json index 1a9d56390..36636c534 100644 --- a/api/package.json +++ b/api/package.json @@ -30,6 +30,7 @@ "graphql": "^16.9.0", "graphql-upload-ts": "^2.1.2", "ical-generator": "^7.0.0", + "openid-client": "6.8.1", "postgraphile": "4.13.0", "postgraphile-plugin-connection-filter": "^2.3.0", "postgres-migrations": "^5.3.0", diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..0bc3aade7 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -51,6 +51,21 @@ export type CTFNoteConfig = DeepReadOnly<{ registrationRoleId: string; channelHandleStyle: DiscordChannelHandleStyle; }; + oauth2: { + enabled: string; + clientId: string; + clientSecret: string; + scope: string; + usernameAttr: string; + roleAttr: string; + roleMapping: string; + discoveryUrl: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + tokenEndpointAuthMethod: string; + issuer: string; + }; }>; function getEnv( @@ -112,6 +127,24 @@ const config: CTFNoteConfig = { "agile" ) as DiscordChannelHandleStyle, }, + oauth2: { + enabled: getEnv("OAUTH2_ENABLED", "false"), + clientId: getEnv("OAUTH2_CLIENT_ID", ""), + clientSecret: getEnv("OAUTH2_CLIENT_SECRET", ""), + scope: getEnv("OAUTH2_SCOPE", ""), + usernameAttr: getEnv("OAUTH2_USERNAME_ATTR", ""), + roleAttr: getEnv("OAUTH2_ROLE_ATTR", ""), + roleMapping: getEnv("OAUTH2_ROLE_MAPPING", ""), + discoveryUrl: getEnv("OAUTH2_DISCOVERY_URL", ""), + authorizationEndpoint: getEnv("OAUTH2_AUTHORIZATION_ENDPOINT", ""), + tokenEndpoint: getEnv("OAUTH2_TOKEN_ENDPOINT", ""), + userinfoEndpoint: getEnv("OAUTH2_USERINFO_ENDPOINT", ""), + tokenEndpointAuthMethod: getEnv( + "OAUTH2_TOKEN_ENDPOINT_AUTH_METHOD", + "client_secret_basic" + ), + issuer: getEnv("OAUTH2_ISSUER", ""), + }, }; export default config; diff --git a/api/src/discord/agile/commands/register.ts b/api/src/discord/agile/commands/register.ts index 3552c6b55..de587913c 100644 --- a/api/src/discord/agile/commands/register.ts +++ b/api/src/discord/agile/commands/register.ts @@ -6,11 +6,11 @@ import { } from "discord.js"; import { Command } from "../../interfaces/command"; import { - AllowedRoles, createInvitationTokenForDiscordId, getInvitationTokenForDiscordId, getUserByDiscordId, } from "../../database/users"; +import { AllowedRoles } from "../../../utils/role"; import config from "../../../config"; async function getInvitationUrl(invitationCode: string | null = null) { diff --git a/api/src/discord/database/users.ts b/api/src/discord/database/users.ts index 86005a4a6..b5182fdcc 100644 --- a/api/src/discord/database/users.ts +++ b/api/src/discord/database/users.ts @@ -1,5 +1,6 @@ import { connectToDatabase } from "../../utils/database"; import { PoolClient } from "pg"; +import { AllowedRoles } from "../../utils/role"; /* * Only returns users that have not linked their discord account yet. @@ -45,15 +46,6 @@ export async function setDiscordIdForUser( } } -// refactor above to an enum -export enum AllowedRoles { - user_guest = "user_guest", - user_friend = "user_friend", - user_member = "user_member", - user_manager = "user_manager", - user_admin = "user_admin", -} - export async function getInvitationTokenForDiscordId( discordId: string, pgClient: PoolClient | null = null diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..55f375e53 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -16,12 +16,17 @@ import uploadLogoPlugin from "./plugins/uploadLogo"; import uploadScalar from "./plugins/uploadScalar"; import { Pool } from "pg"; import { icalRoute } from "./routes/ical"; +import { oauth2Router } from "./routes/oauth2"; import ConnectionFilterPlugin from "postgraphile-plugin-connection-filter"; import OperationHook from "@graphile/operation-hooks"; import discordHooks from "./discord/hooks"; import { initDiscordBot } from "./discord"; import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin"; +import { + checkOAuth2Enabled, + loginWithOAuth2Plugin, +} from "./plugins/loginWithOAuth2"; function getDbUrl(role: "user" | "admin") { const login = config.db[role].login; @@ -63,6 +68,7 @@ function createOptions() { discordHooks, PgManyToManyPlugin, ProfileSubscriptionPlugin, + loginWithOAuth2Plugin, ], ownerConnectionString: getDbUrl("admin"), enableQueryBatching: true, @@ -109,7 +115,7 @@ function createOptions() { return postgraphileOptions; } -function createApp(postgraphileOptions: PostGraphileOptions) { +async function createApp(postgraphileOptions: PostGraphileOptions) { const pool = new Pool({ connectionString: getDbUrl("user"), }); @@ -126,6 +132,9 @@ function createApp(postgraphileOptions: PostGraphileOptions) { ); app.use(postgraphile(pool, "ctfnote", postgraphileOptions)); app.use("/calendar.ics", icalRoute(pool)); + if (await checkOAuth2Enabled()) { + app.use("/api/auth/oauth2", oauth2Router); + } return app; } @@ -150,7 +159,7 @@ async function main() { return; } const postgraphileOptions = createOptions(); - const app = createApp(postgraphileOptions); + const app = await createApp(postgraphileOptions); await initDiscordBot(); diff --git a/api/src/plugins/loginWithOAuth2.ts b/api/src/plugins/loginWithOAuth2.ts new file mode 100644 index 000000000..28901cf0d --- /dev/null +++ b/api/src/plugins/loginWithOAuth2.ts @@ -0,0 +1,226 @@ +import { makeExtendSchemaPlugin, gql } from "graphile-utils"; +import { connectToDatabase } from "../utils/database"; +import savepointWrapper from "./savepointWrapper"; +import config from "../config"; +import { determineRoleByMapping } from "../utils/role"; +import { AllowedRoles } from "../utils/role"; + +// relevant excerpt from .well-known/openid-configuration +type DiscoveryJson = { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + issuer: string; +}; + +export type OAuth2Metadata = { + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + issuer: string; +}; + +let oauth2Metadata: OAuth2Metadata = { + authorizationEndpoint: config.oauth2.authorizationEndpoint, + tokenEndpoint: config.oauth2.tokenEndpoint, + userinfoEndpoint: config.oauth2.userinfoEndpoint, + issuer: config.oauth2.issuer, +}; + +export async function checkOAuth2Enabled() { + let enabled = config.oauth2.enabled === "true"; + const errors: string[] = []; + + if (enabled) { + if (!config.oauth2.clientId) { + errors.push("clientId is missing"); + } + if (!config.oauth2.scope) { + errors.push("scopes is missing"); + } + if (!config.oauth2.usernameAttr) { + errors.push("usernameAttr is missing"); + } + if (!config.oauth2.roleAttr) { + errors.push("roleAttr is missing"); + } + if (!config.oauth2.roleMapping) { + errors.push("roleMapping is missing"); + } + if (config.oauth2.discoveryUrl) { + try { + const discoverResponse = await fetch(config.oauth2.discoveryUrl); + const discoveryJson: DiscoveryJson = await discoverResponse.json(); + oauth2Metadata = { + authorizationEndpoint: + config.oauth2.authorizationEndpoint || + discoveryJson.authorization_endpoint, + tokenEndpoint: + config.oauth2.tokenEndpoint || discoveryJson.token_endpoint, + userinfoEndpoint: + config.oauth2.userinfoEndpoint || discoveryJson.userinfo_endpoint, + issuer: config.oauth2.issuer || discoveryJson.issuer, + }; + } catch (error) { + console.error(`Failed to fetch ${config.oauth2.discoveryUrl}:`, error); + } + } + if (!oauth2Metadata.authorizationEndpoint) { + errors.push("authorizationEndpoint is missing"); + } + if (!oauth2Metadata.tokenEndpoint) { + errors.push("tokenEndpoint is missing"); + } + if (!oauth2Metadata.userinfoEndpoint) { + errors.push("userinfoEndpoint is missing"); + } + if (!oauth2Metadata.issuer) { + errors.push("issuer is missing"); + } + + if (errors.length) { + enabled = false; + console.error("Disabling OAuth2 due to configuration errors:"); + for (const message of errors) { + console.error(` - ${message}`); + } + } + } + + const pgClient = await connectToDatabase(); + try { + const query = "UPDATE ctfnote.settings SET oauth2_enabled = $1"; + await pgClient.query(query, [enabled]); + } catch (error) { + console.error("Failed to set oauth2_enabled flag in the database:", error); + } finally { + pgClient.release(); + } + + return enabled; +} + +export const loginWithOAuth2Plugin = makeExtendSchemaPlugin(() => { + return { + typeDefs: gql` + input LoginWithOAuth2Input { + callbackUrl: String! + pkceCodeVerifier: String + expectedState: String + } + + type LoginWithOAuth2Payload { + jwt: Jwt + login: String + } + + type OAuth2Settings { + clientId: String! + scope: String! + issuer: String! + authorizationEndpoint: String! + } + + extend type Query { + oauth2Settings: OAuth2Settings + } + + extend type Mutation { + loginWithOAuth2(input: LoginWithOAuth2Input): LoginWithOAuth2Payload + } + `, + resolvers: { + Query: { + oauth2Settings: () => { + return { + clientId: config.oauth2.clientId, + scope: config.oauth2.scope, + issuer: oauth2Metadata.issuer, + authorizationEndpoint: oauth2Metadata.authorizationEndpoint, + }; + }, + }, + Mutation: { + loginWithOAuth2: async ( + _query, + { input: { callbackUrl, expectedState, pkceCodeVerifier } }, + { pgClient } + ) => { + if (!config.oauth2.enabled) { + throw new Error("OAuth 2.0 login is not enabled"); + } + + const oauth2 = await import("openid-client"); + + let clientAuth; + switch (config.oauth2.tokenEndpointAuthMethod) { + case "client_secret_post": + clientAuth = oauth2.ClientSecretPost(config.oauth2.clientSecret); + break; + case "client_secret_jwt": + clientAuth = oauth2.ClientSecretJwt(config.oauth2.clientSecret); + break; + case "client_secret_basic": + clientAuth = oauth2.ClientSecretBasic(config.oauth2.clientSecret); + break; + case "none": + clientAuth = undefined; + break; + } + + const oauth2Config = new oauth2.Configuration( + { + issuer: oauth2Metadata.issuer, + token_endpoint: oauth2Metadata.tokenEndpoint, + }, + config.oauth2.clientId, + undefined, + clientAuth + ); + + const tokenResponse = await oauth2.authorizationCodeGrant( + oauth2Config, + new URL(callbackUrl), + { + pkceCodeVerifier: pkceCodeVerifier, + expectedState: expectedState, + } + ); + + const profileResponse = await oauth2.fetchProtectedResource( + oauth2Config, + tokenResponse.access_token, + new URL(oauth2Metadata.userinfoEndpoint), + "GET" + ); + + const profile = await profileResponse.json(); + const login = profile[config.oauth2.usernameAttr]; + if (!login) { + throw new Error("Login is missing in profile"); + } + const roleAttr = profile[config.oauth2.roleAttr]; + if (!roleAttr) { + throw new Error("Role is missing in profile"); + } + const roleMapping: Record = + JSON.parse(config.oauth2.roleMapping); + const role = determineRoleByMapping(roleAttr, roleMapping); + if (!role) { + throw new Error("Access denied"); + } + + return await savepointWrapper(pgClient, async () => { + const { + rows: [jwt], + } = await pgClient.query( + `SELECT * FROM ctfnote_private.login_with_extern($1, $2)`, + [login, role] + ); + return { jwt, login }; + }); + }, + }, + }, + }; +}); diff --git a/api/src/routes/oauth2.ts b/api/src/routes/oauth2.ts new file mode 100644 index 000000000..9fdc4022f --- /dev/null +++ b/api/src/routes/oauth2.ts @@ -0,0 +1,9 @@ +import { Request, Response, Router } from "express"; + +export const oauth2Router = Router(); + +oauth2Router.get("/callback", (req: Request, res: Response) => { + res.redirect( + `/#/auth/oauth2/callback/${encodeURIComponent(req.originalUrl)}` + ); +}); diff --git a/api/src/utils/role.ts b/api/src/utils/role.ts new file mode 100644 index 000000000..55cfdacbf --- /dev/null +++ b/api/src/utils/role.ts @@ -0,0 +1,30 @@ +export enum AllowedRoles { + user_guest = "user_guest", + user_friend = "user_friend", + user_member = "user_member", + user_manager = "user_manager", + user_admin = "user_admin", +} + +export function determineRoleByMapping( + input: string | string[], + mapping: Record +): AllowedRoles | undefined { + if (typeof input === "string") { + return mapping[input]; + } + + const roles = input.map((value) => mapping[value]); + for (const role of [ + AllowedRoles.user_admin, + AllowedRoles.user_manager, + AllowedRoles.user_member, + AllowedRoles.user_friend, + AllowedRoles.user_guest, + ]) { + if (roles.includes(role)) { + return role; + } + } + return undefined; +} diff --git a/api/yarn.lock b/api/yarn.lock index 01811db16..8d00a9a1e 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1310,6 +1310,7 @@ __metadata: ical-generator: "npm:^7.0.0" lint-staged: "npm:^15.2.2" nodemon: "npm:^3.1.7" + openid-client: "npm:6.8.1" postgraphile: "npm:4.13.0" postgraphile-plugin-connection-filter: "npm:^2.3.0" postgres-migrations: "npm:^5.3.0" @@ -2596,6 +2597,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.0": + version: 6.1.0 + resolution: "jose@npm:6.1.0" + checksum: 10/d111bc11fdb9566370992393615097d58618ba6ed29eee5d79b3bf4c460c93a6c0a9c5829d14b40342b376df16cde7d1bef79fda65c6bf09e1158875c17ae865 + languageName: node + linkType: hard + "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -3182,6 +3190,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:^3.8.2": + version: 3.8.2 + resolution: "oauth4webapi@npm:3.8.2" + checksum: 10/5f96b50dd50298402a15d5d545decab0ef20aa14fdd0fe6d6ccc9e47d645d795f0aebb0c1fd452e2504170d8880b89021a87a168aed4df64bf39a1ba7e85f245 + languageName: node + linkType: hard + "object-inspect@npm:^1.13.1": version: 1.13.3 resolution: "object-inspect@npm:1.13.3" @@ -3232,6 +3247,16 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:6.8.1": + version: 6.8.1 + resolution: "openid-client@npm:6.8.1" + dependencies: + jose: "npm:^6.1.0" + oauth4webapi: "npm:^3.8.2" + checksum: 10/94d18d39f8e9d4a194acdba85793a1e39a0f6fe50fdf2bdea4aa57409da629a8966a3673738510c5e9f6a32715259e26fb6e56e83b1b8d2de31346353aa437ad + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" diff --git a/front/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip b/front/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip new file mode 100644 index 000000000..3dd0ee495 Binary files /dev/null and b/front/.yarn/cache/jose-npm-6.1.0-b52bb87803-d111bc11fd.zip differ diff --git a/front/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip b/front/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip new file mode 100644 index 000000000..23b7fb385 Binary files /dev/null and b/front/.yarn/cache/oauth4webapi-npm-3.8.2-251c3d9d97-5f96b50dd5.zip differ diff --git a/front/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip b/front/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip new file mode 100644 index 000000000..b4ef7a17e Binary files /dev/null and b/front/.yarn/cache/openid-client-npm-6.8.1-616ccc5934-94d18d39f8.zip differ diff --git a/front/graphql.schema.json b/front/graphql.schema.json index 2845f01c5..2aab6defe 100644 --- a/front/graphql.schema.json +++ b/front/graphql.schema.json @@ -6162,6 +6162,94 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "LoginWithOAuth2Input", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "callbackUrl", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expectedState", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pkceCodeVerifier", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LoginWithOAuth2Payload", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "jwt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Jwt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "login", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Mutation", @@ -6885,6 +6973,31 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "loginWithOAuth2", + "description": null, + "args": [ + { + "name": "input", + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "type": { + "kind": "INPUT_OBJECT", + "name": "LoginWithOAuth2Input", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginWithOAuth2Payload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "register", "description": null, @@ -7728,6 +7841,82 @@ } ] }, + { + "kind": "OBJECT", + "name": "OAuth2Settings", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "authorizationEndpoint", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "scope", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "PageInfo", @@ -10347,6 +10536,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "oauth2Settings", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "OAuth2Settings", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "pastCtf", "description": "Reads and enables pagination through a set of `Ctf`.", @@ -12515,6 +12716,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "oauth2Enabled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "registrationAllowed", "description": null, @@ -12638,6 +12855,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "oauth2Enabled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "registrationAllowed", "description": null, diff --git a/front/nginx.conf b/front/nginx.conf index 9ac8eb36f..a87b354f6 100644 --- a/front/nginx.conf +++ b/front/nginx.conf @@ -38,6 +38,17 @@ server { add_header Pragma "no-cache"; } + location /api { + proxy_pass http://api:3000/api; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Pragma "no-cache"; + } + location /calendar.ics { proxy_pass http://api:3000/calendar.ics; proxy_http_version 1.1; diff --git a/front/package.json b/front/package.json index cb1f28d20..54d775080 100644 --- a/front/package.json +++ b/front/package.json @@ -25,6 +25,7 @@ "core-js": "^3.39.0", "hotkeys-js": "^3.13.7", "jszip": "^3.10.1", + "openid-client": "^6.8.1", "quasar": "^2.17.4", "slugify": "^1.6.6", "ts-essentials": "^9.4.1", diff --git a/front/src/components/Auth/Login.vue b/front/src/components/Auth/Login.vue index 1c990aa07..d3d7c3430 100644 --- a/front/src/components/Auth/Login.vue +++ b/front/src/components/Auth/Login.vue @@ -29,6 +29,19 @@ +
+ +
or
+ +
+
@@ -49,6 +62,7 @@ import PasswordInput from 'src/components/Utils/PasswordInput.vue'; import { ctfnote } from 'src/ctfnote'; import { defineComponent, reactive } from 'vue'; import CtfNoteLink from '../Utils/CtfNoteLink.vue'; +import * as oauth2 from 'openid-client'; export default defineComponent({ components: { PasswordInput, CtfNoteLink }, @@ -58,6 +72,7 @@ export default defineComponent({ setup() { return { settings: ctfnote.settings.injectSettings(), + oauth2Settings: ctfnote.settings.getOAuth2Settings().result, resolveAndNotify: ctfnote.ui.useNotify().resolveAndNotify, login: ctfnote.auth.useLogin(), allowRegistration: true, @@ -86,6 +101,33 @@ export default defineComponent({ }, ); }, + async redirectToOAuth2() { + const oauth2Config = new oauth2.Configuration( + { + issuer: this.oauth2Settings.issuer, + authorization_endpoint: this.oauth2Settings.authorizationEndpoint, + }, + this.oauth2Settings.clientId, + ); + + const pkceCodeVerifier = oauth2.randomPKCECodeVerifier(); + const pkceCodeChallenge = + await oauth2.calculatePKCECodeChallenge(pkceCodeVerifier); + const state = oauth2.randomState(); + sessionStorage.setItem('pkceCodeVerifier', pkceCodeVerifier); + sessionStorage.setItem('state', state); + + let params: Record = { + state, + scope: this.oauth2Settings.scope, + redirect_uri: document.location.origin + '/api/auth/oauth2/callback', + code_challenge: pkceCodeChallenge, + code_challenge_method: 'S256', + }; + document.location.replace( + oauth2.buildAuthorizationUrl(oauth2Config, params), + ); + }, }, }); diff --git a/front/src/components/Auth/OAuth2Callback.vue b/front/src/components/Auth/OAuth2Callback.vue new file mode 100644 index 000000000..8239bb925 --- /dev/null +++ b/front/src/components/Auth/OAuth2Callback.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/front/src/ctfnote/auth.ts b/front/src/ctfnote/auth.ts index 6aa7c97f5..5c6db667c 100644 --- a/front/src/ctfnote/auth.ts +++ b/front/src/ctfnote/auth.ts @@ -2,6 +2,7 @@ import { useApolloClient } from '@vue/apollo-composable'; import { NewTokenDocument, useLoginMutation, + useLoginWithOAuth2Mutation, useRegisterMutation, useRegisterWithPasswordMutation, useRegisterWithTokenMutation, @@ -54,6 +55,27 @@ export function useLogin() { }; } +export function useLoginWithOAuth2() { + const { mutate } = useLoginWithOAuth2Mutation({}); + const $router = useRouter(); + return async ( + callbackUrl: string, + expectedState?: string, + pkceCodeVerifier?: string, + ) => { + const r = await mutate({ callbackUrl, expectedState, pkceCodeVerifier }); + const jwt = r?.data?.loginWithOAuth2?.jwt; + if (jwt) { + saveJWT(jwt); + await prefetchMe(); + await $router.push({ name: 'ctfs-incoming' }); + return r?.data?.loginWithOAuth2?.login; + } else { + throw new Error('Login succeeded without JWT. Please try again.'); + } + }; +} + export function useRegister() { const { mutate } = useRegisterMutation({}); const $router = useRouter(); diff --git a/front/src/ctfnote/models.ts b/front/src/ctfnote/models.ts index 00debfa7a..87abd6368 100644 --- a/front/src/ctfnote/models.ts +++ b/front/src/ctfnote/models.ts @@ -103,6 +103,7 @@ export type Settings = { registrationPasswordAllowed: boolean; style: SettingsColorMap; discordIntegrationEnabled: boolean; + oauth2Enabled: boolean; }; export type AdminSettings = Settings & { @@ -111,6 +112,13 @@ export type AdminSettings = Settings & { icalPassword: string; }; +export type OAuth2Settings = { + clientId: string; + scope: string; + issuer: string; + authorizationEndpoint: string; +}; + export type User = { nodeId: string; diff --git a/front/src/ctfnote/settings.ts b/front/src/ctfnote/settings.ts index 38e3a9fd7..3f0ed72d4 100644 --- a/front/src/ctfnote/settings.ts +++ b/front/src/ctfnote/settings.ts @@ -2,11 +2,13 @@ import { useApolloClient } from '@vue/apollo-composable'; import { AdminSettingsInfoFragment, GetSettingsDocument, + OAuth2SettingsFragment, Role, SettingPatch, SettingsInfoFragment, useGetAdminSettingsQuery, useGetIcalPasswordQuery, + useGetOAuth2SettingsQuery, useGetSettingsQuery, useUpdateSettingsMutation, } from 'src/generated/graphql'; @@ -14,6 +16,7 @@ import { inject, InjectionKey, provide, Ref } from 'vue'; import { AdminSettings, defaultColorsNames, + OAuth2Settings, Settings, SettingsColorMap, } from './models'; @@ -41,6 +44,7 @@ export function buildSettings( registrationPasswordAllowed: fragment.registrationPasswordAllowed ?? false, style: parseStyle(fragment.style ?? '{}'), discordIntegrationEnabled: fragment.discordIntegrationEnabled ?? false, + oauth2Enabled: fragment.oauth2Enabled ?? false, }; } @@ -56,6 +60,17 @@ export function buildAdminSettings( }; } +export function buildOAuth2Settings( + fragment: Partial, +): OAuth2Settings { + return { + clientId: fragment.clientId ?? '', + scope: fragment.scope ?? '', + issuer: fragment.issuer ?? '', + authorizationEndpoint: fragment.authorizationEndpoint ?? '', + }; +} + /* Prefetch */ export function prefetchSettings() { @@ -106,6 +121,13 @@ export function getIcalPassword() { }); } +export function getOAuth2Settings() { + const r = useGetOAuth2SettingsQuery(); + return wrapQuery(r, buildOAuth2Settings({}), (data) => { + return buildOAuth2Settings(data.oauth2Settings); + }); +} + /* Mutations */ export function useUpdateSettings() { diff --git a/front/src/generated/graphql.ts b/front/src/generated/graphql.ts index ecd1b0406..23028f81e 100644 --- a/front/src/generated/graphql.ts +++ b/front/src/generated/graphql.ts @@ -1188,6 +1188,18 @@ export type LoginPayload = { query?: Maybe; }; +export type LoginWithOAuth2Input = { + callbackUrl: Scalars['String']['input']; + expectedState?: InputMaybe; + pkceCodeVerifier?: InputMaybe; +}; + +export type LoginWithOAuth2Payload = { + __typename?: 'LoginWithOAuth2Payload'; + jwt?: Maybe; + login?: Maybe; +}; + /** The root mutation type which contains root level fields which mutate data. */ export type Mutation = { __typename?: 'Mutation'; @@ -1231,6 +1243,7 @@ export type Mutation = { deleteWorkOnTaskByNodeId?: Maybe; importCtf?: Maybe; login?: Maybe; + loginWithOAuth2?: Maybe; register?: Maybe; registerWithPassword?: Maybe; registerWithToken?: Maybe; @@ -1423,6 +1436,12 @@ export type MutationLoginArgs = { }; +/** The root mutation type which contains root level fields which mutate data. */ +export type MutationLoginWithOAuth2Args = { + input?: InputMaybe; +}; + + /** The root mutation type which contains root level fields which mutate data. */ export type MutationRegisterArgs = { input: RegisterInput; @@ -1584,6 +1603,14 @@ export type Node = { nodeId: Scalars['ID']['output']; }; +export type OAuth2Settings = { + __typename?: 'OAuth2Settings'; + authorizationEndpoint: Scalars['String']['output']; + clientId: Scalars['String']['output']; + issuer: Scalars['String']['output']; + scope: Scalars['String']['output']; +}; + /** Information about pagination in a connection. */ export type PageInfo = { __typename?: 'PageInfo'; @@ -1857,6 +1884,7 @@ export type Query = Node & { node?: Maybe; /** The root query type must be a `Node` to work well with Relay 1 mutations. This just resolves to `query`. */ nodeId: Scalars['ID']['output']; + oauth2Settings?: Maybe; /** Reads and enables pagination through a set of `Ctf`. */ pastCtf?: Maybe; profile?: Maybe; @@ -2407,6 +2435,7 @@ export type Setting = Node & { icalPassword?: Maybe; /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ nodeId: Scalars['ID']['output']; + oauth2Enabled: Scalars['Boolean']['output']; registrationAllowed: Scalars['Boolean']['output']; registrationDefaultRole: Role; registrationPassword: Scalars['String']['output']; @@ -2418,6 +2447,7 @@ export type Setting = Node & { export type SettingPatch = { discordIntegrationEnabled?: InputMaybe; icalPassword?: InputMaybe; + oauth2Enabled?: InputMaybe; registrationAllowed?: InputMaybe; registrationDefaultRole?: InputMaybe; registrationPassword?: InputMaybe; @@ -3519,6 +3549,15 @@ export type LoginMutationVariables = Exact<{ export type LoginMutation = { __typename?: 'Mutation', login?: { __typename?: 'LoginPayload', jwt?: string | null } | null }; +export type LoginWithOAuth2MutationVariables = Exact<{ + callbackUrl: Scalars['String']['input']; + expectedState?: InputMaybe; + pkceCodeVerifier?: InputMaybe; +}>; + + +export type LoginWithOAuth2Mutation = { __typename?: 'Mutation', loginWithOAuth2?: { __typename?: 'LoginWithOAuth2Payload', jwt?: string | null, login?: string | null } | null }; + export type RegisterMutationVariables = Exact<{ login: Scalars['String']['input']; password: Scalars['String']['input']; @@ -3768,14 +3807,21 @@ export type UpdateCredentialsForCtfIdMutationVariables = Exact<{ export type UpdateCredentialsForCtfIdMutation = { __typename?: 'Mutation', updateCtfSecret?: { __typename?: 'UpdateCtfSecretPayload', ctfSecret?: { __typename?: 'CtfSecret', nodeId: string, credentials?: string | null } | null } | null }; -export type SettingsInfoFragment = { __typename?: 'Setting', nodeId: string, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean }; +export type SettingsInfoFragment = { __typename?: 'Setting', nodeId: string, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean, oauth2Enabled: boolean }; -export type AdminSettingsInfoFragment = { __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean }; +export type AdminSettingsInfoFragment = { __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean, oauth2Enabled: boolean }; + +export type OAuth2SettingsFragment = { __typename?: 'OAuth2Settings', clientId: string, scope: string, issuer: string, authorizationEndpoint: string }; export type GetSettingsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetSettingsQuery = { __typename?: 'Query', settings?: { __typename?: 'SettingsConnection', nodes: Array<{ __typename?: 'Setting', nodeId: string, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean }> } | null }; +export type GetSettingsQuery = { __typename?: 'Query', settings?: { __typename?: 'SettingsConnection', nodes: Array<{ __typename?: 'Setting', nodeId: string, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean, oauth2Enabled: boolean }> } | null }; + +export type GetOAuth2SettingsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetOAuth2SettingsQuery = { __typename?: 'Query', oauth2Settings?: { __typename?: 'OAuth2Settings', clientId: string, scope: string, issuer: string, authorizationEndpoint: string } | null }; export type GetIcalPasswordQueryVariables = Exact<{ [key: string]: never; }>; @@ -3785,7 +3831,7 @@ export type GetIcalPasswordQuery = { __typename?: 'Query', settings?: { __typena export type GetAdminSettingsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetAdminSettingsQuery = { __typename?: 'Query', settings?: { __typename?: 'SettingsConnection', nodes: Array<{ __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean }> } | null }; +export type GetAdminSettingsQuery = { __typename?: 'Query', settings?: { __typename?: 'SettingsConnection', nodes: Array<{ __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean, oauth2Enabled: boolean }> } | null }; export type UpdateSettingsMutationVariables = Exact<{ nodeId: Scalars['ID']['input']; @@ -3793,7 +3839,7 @@ export type UpdateSettingsMutationVariables = Exact<{ }>; -export type UpdateSettingsMutation = { __typename?: 'Mutation', updateSettingByNodeId?: { __typename?: 'UpdateSettingPayload', setting?: { __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean } | null } | null }; +export type UpdateSettingsMutation = { __typename?: 'Mutation', updateSettingByNodeId?: { __typename?: 'UpdateSettingPayload', setting?: { __typename?: 'Setting', nodeId: string, registrationPassword: string, registrationDefaultRole: Role, icalPassword?: string | null, registrationAllowed: boolean, registrationPasswordAllowed: boolean, style: string, discordIntegrationEnabled: boolean, oauth2Enabled: boolean } | null } | null }; export type TagFragment = { __typename?: 'Tag', nodeId: string, id: number, tag: string }; @@ -4063,6 +4109,7 @@ export const SettingsInfoFragmentDoc = gql` registrationPasswordAllowed style discordIntegrationEnabled + oauth2Enabled } `; export const AdminSettingsInfoFragmentDoc = gql` @@ -4074,6 +4121,14 @@ export const AdminSettingsInfoFragmentDoc = gql` icalPassword } ${SettingsInfoFragmentDoc}`; +export const OAuth2SettingsFragmentDoc = gql` + fragment OAuth2SettingsFragment on OAuth2Settings { + clientId + scope + issuer + authorizationEndpoint +} + `; export const TaskForTagsFragementFragmentDoc = gql` fragment TaskForTagsFragement on AssignedTag { nodeId @@ -4459,6 +4514,40 @@ export function useLoginMutation(options: VueApolloComposable.UseMutationOptions return VueApolloComposable.useMutation(LoginDocument, options); } export type LoginMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; +export const LoginWithOAuth2Document = gql` + mutation LoginWithOAuth2($callbackUrl: String!, $expectedState: String, $pkceCodeVerifier: String) { + loginWithOAuth2( + input: {callbackUrl: $callbackUrl, expectedState: $expectedState, pkceCodeVerifier: $pkceCodeVerifier} + ) { + jwt + login + } +} + `; + +/** + * __useLoginWithOAuth2Mutation__ + * + * To run a mutation, you first call `useLoginWithOAuth2Mutation` within a Vue component and pass it any options that fit your needs. + * When your component renders, `useLoginWithOAuth2Mutation` returns an object that includes: + * - A mutate function that you can call at any time to execute the mutation + * - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return + * + * @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options; + * + * @example + * const { mutate, loading, error, onDone } = useLoginWithOAuth2Mutation({ + * variables: { + * callbackUrl: // value for 'callbackUrl' + * expectedState: // value for 'expectedState' + * pkceCodeVerifier: // value for 'pkceCodeVerifier' + * }, + * }); + */ +export function useLoginWithOAuth2Mutation(options: VueApolloComposable.UseMutationOptions | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(LoginWithOAuth2Document, options); +} +export type LoginWithOAuth2MutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; export const RegisterDocument = gql` mutation Register($login: String!, $password: String!) { register(input: {login: $login, password: $password}) { @@ -5544,6 +5633,33 @@ export function useGetSettingsLazyQuery(options: VueApolloComposable.UseQueryOpt return VueApolloComposable.useLazyQuery(GetSettingsDocument, {}, options); } export type GetSettingsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn; +export const GetOAuth2SettingsDocument = gql` + query getOAuth2Settings { + oauth2Settings { + ...OAuth2SettingsFragment + } +} + ${OAuth2SettingsFragmentDoc}`; + +/** + * __useGetOAuth2SettingsQuery__ + * + * To run a query within a Vue component, call `useGetOAuth2SettingsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetOAuth2SettingsQuery` returns an object from Apollo Client that contains result, loading and error properties + * you can use to render your UI. + * + * @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options; + * + * @example + * const { result, loading, error } = useGetOAuth2SettingsQuery(); + */ +export function useGetOAuth2SettingsQuery(options: VueApolloComposable.UseQueryOptions | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useQuery(GetOAuth2SettingsDocument, {}, options); +} +export function useGetOAuth2SettingsLazyQuery(options: VueApolloComposable.UseQueryOptions | VueCompositionApi.Ref> | ReactiveFunction> = {}) { + return VueApolloComposable.useLazyQuery(GetOAuth2SettingsDocument, {}, options); +} +export type GetOAuth2SettingsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn; export const GetIcalPasswordDocument = gql` query getIcalPassword { settings { @@ -6300,6 +6416,7 @@ export const SettingsInfo = gql` registrationPasswordAllowed style discordIntegrationEnabled + oauth2Enabled } `; export const AdminSettingsInfo = gql` @@ -6311,6 +6428,14 @@ export const AdminSettingsInfo = gql` icalPassword } ${SettingsInfo}`; +export const OAuth2SettingsFragment = gql` + fragment OAuth2SettingsFragment on OAuth2Settings { + clientId + scope + issuer + authorizationEndpoint +} + `; export const TaskForTagsFragement = gql` fragment TaskForTagsFragement on AssignedTag { nodeId @@ -6422,6 +6547,16 @@ export const Login = gql` } } `; +export const LoginWithOAuth2 = gql` + mutation LoginWithOAuth2($callbackUrl: String!, $expectedState: String, $pkceCodeVerifier: String) { + loginWithOAuth2( + input: {callbackUrl: $callbackUrl, expectedState: $expectedState, pkceCodeVerifier: $pkceCodeVerifier} + ) { + jwt + login + } +} + `; export const Register = gql` mutation Register($login: String!, $password: String!) { register(input: {login: $login, password: $password}) { @@ -6774,6 +6909,13 @@ export const GetSettings = gql` } } ${SettingsInfo}`; +export const GetOAuth2Settings = gql` + query getOAuth2Settings { + oauth2Settings { + ...OAuth2SettingsFragment + } +} + ${OAuth2SettingsFragment}`; export const GetIcalPassword = gql` query getIcalPassword { settings { diff --git a/front/src/graphql/Auth.graphql b/front/src/graphql/Auth.graphql index 91e6c72d4..cf5d3642a 100644 --- a/front/src/graphql/Auth.graphql +++ b/front/src/graphql/Auth.graphql @@ -32,6 +32,23 @@ mutation Login($login: String!, $password: String!) { } } +mutation LoginWithOAuth2( + $callbackUrl: String! + $expectedState: String + $pkceCodeVerifier: String +) { + loginWithOAuth2( + input: { + callbackUrl: $callbackUrl + expectedState: $expectedState + pkceCodeVerifier: $pkceCodeVerifier + } + ) { + jwt + login + } +} + mutation Register($login: String!, $password: String!) { register(input: { login: $login, password: $password }) { jwt diff --git a/front/src/graphql/Settings.graphql b/front/src/graphql/Settings.graphql index c3cc15a32..798b66ab8 100644 --- a/front/src/graphql/Settings.graphql +++ b/front/src/graphql/Settings.graphql @@ -4,6 +4,7 @@ fragment SettingsInfo on Setting { registrationPasswordAllowed style discordIntegrationEnabled + oauth2Enabled } fragment AdminSettingsInfo on Setting { @@ -14,6 +15,13 @@ fragment AdminSettingsInfo on Setting { icalPassword } +fragment OAuth2SettingsFragment on OAuth2Settings { + clientId + scope + issuer + authorizationEndpoint +} + query getSettings { settings { nodes { @@ -22,6 +30,12 @@ query getSettings { } } +query getOAuth2Settings { + oauth2Settings { + ...OAuth2SettingsFragment + } +} + query getIcalPassword { settings { nodes { diff --git a/front/src/router/routes.ts b/front/src/router/routes.ts index 13c33f033..e1de8347e 100644 --- a/front/src/router/routes.ts +++ b/front/src/router/routes.ts @@ -118,6 +118,13 @@ const authRoute: RouteRecordRaw = { props: (route) => ({ token: route.params.token }), component: () => import('components/Auth/ResetPassword.vue'), }, + { + path: 'oauth2/callback/:callbackUrl', + name: 'auth-oauth2-callback', + meta: { public: true, title: 'OAuth2 callback' }, + props: (route) => ({ callbackUrl: route.params.callbackUrl }), + component: () => import('components/Auth/OAuth2Callback.vue'), + }, ], }; diff --git a/front/yarn.lock b/front/yarn.lock index 4874a28c3..90f290797 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -6712,6 +6712,7 @@ __metadata: hotkeys-js: "npm:^3.13.7" jszip: "npm:^3.10.1" lint-staged: "npm:^15.2.10" + openid-client: "npm:^6.8.1" prettier: "npm:^3.3.3" quasar: "npm:^2.17.4" slugify: "npm:^1.6.6" @@ -9621,6 +9622,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.0": + version: 6.1.0 + resolution: "jose@npm:6.1.0" + checksum: 10/d111bc11fdb9566370992393615097d58618ba6ed29eee5d79b3bf4c460c93a6c0a9c5829d14b40342b376df16cde7d1bef79fda65c6bf09e1158875c17ae865 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -10968,6 +10976,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:^3.8.2": + version: 3.8.2 + resolution: "oauth4webapi@npm:3.8.2" + checksum: 10/5f96b50dd50298402a15d5d545decab0ef20aa14fdd0fe6d6ccc9e47d645d795f0aebb0c1fd452e2504170d8880b89021a87a168aed4df64bf39a1ba7e85f245 + languageName: node + linkType: hard + "object-assign@npm:^4, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -11103,6 +11118,16 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^6.8.1": + version: 6.8.1 + resolution: "openid-client@npm:6.8.1" + dependencies: + jose: "npm:^6.1.0" + oauth4webapi: "npm:^3.8.2" + checksum: 10/94d18d39f8e9d4a194acdba85793a1e39a0f6fe50fdf2bdea4aa57409da629a8966a3673738510c5e9f6a32715259e26fb6e56e83b1b8d2de31346353aa437ad + languageName: node + linkType: hard + "optimism@npm:^0.18.0": version: 0.18.0 resolution: "optimism@npm:0.18.0"