diff --git a/README.md b/README.md index 9742f99..791daf0 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,9 @@ gamja default settings can be set using a `config.json` file at the root: "autojoin": "#gamja", // Controls how the password UI is presented to the user. Set to // "mandatory" to require a password, "optional" to accept one but not - // require it, "disabled" to never ask for a password, or "external" to - // use SASL EXTERNAL. Defaults to "optional". + // require it, "disabled" to never ask for a password, "external" to + // use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to + // "optional". "auth": "optional", // Default nickname (string). If it contains a "*" character, it will // be replaced with a random string. @@ -116,6 +117,19 @@ gamja default settings can be set using a `config.json` file at the root: // disable. Enabling PINGs can have an impact on client power usage and // should only be enabled if necessary. "ping": 60 + }, + // OAuth 2.0 settings. + "oauth2": { + // OAuth 2.0 server URL (string). The server must support OAuth 2.0 + // Authorization Server Metadata (RFC 8414) or OpenID Connect + // Discovery. + "url": "https://auth.example.org", + // OAuth 2.0 client ID (string). + "client_id": "asdf", + // OAuth 2.0 client secret (string). + "client_secret": "ghjk", + // OAuth 2.0 scope (string). + "scope": "profile" } } ``` diff --git a/components/app.js b/components/app.js index 5c2b76f..d3c60ae 100644 --- a/components/app.js +++ b/components/app.js @@ -1,5 +1,6 @@ import * as irc from "../lib/irc.js"; import Client from "../lib/client.js"; +import * as oauth2 from "../lib/oauth2.js"; import Buffer from "./buffer.js"; import BufferList from "./buffer-list.js"; import BufferHeader from "./buffer-header.js"; @@ -249,7 +250,7 @@ export default class App extends Component { * - Default server URL constructed from the current URL location (this is * done in fillConnectParams) */ - handleConfig(config) { + async handleConfig(config) { let connectParams = { ...this.state.connectParams }; if (typeof config.server.url === "string") { @@ -277,6 +278,10 @@ export default class App extends Component { console.error("Error in config.json: cannot set server.autoconnect = true and server.auth = \"mandatory\""); connectParams.autoconnect = false; } + if (config.server.auth === "oauth2" && (!config.oauth2 || !config.oauth2.url || !config.oauth2.client_id)) { + console.error("Error in config.json: server.auth = \"oauth2\" requires oauth2 settings"); + config.server.auth = null; + } let autoconnect = store.autoconnect.load(); if (autoconnect) { @@ -329,6 +334,40 @@ export default class App extends Component { connectParams.nick = connectParams.nick.replace("*", placeholder); } + if (config.server.auth === "oauth2" && !connectParams.saslOauthBearer) { + if (queryParams.error) { + console.error("OAuth 2.0 authorization failed: ", queryParams.error); + this.showError("Authentication failed: " + (queryParams.error_description || queryParams.error)); + return; + } + + if (!queryParams.code) { + this.redirectOauth2Authorize(); + return; + } + + // Strip code from query params, to prevent page refreshes from + // trying to exchange the code again + let url = new URL(window.location.toString()); + url.searchParams.delete("code"); + url.searchParams.delete("state"); + window.history.replaceState(null, "", url.toString()); + + let saslOauthBearer; + try { + saslOauthBearer = await this.exchangeOauth2Code(queryParams.code); + } catch (err) { + this.showError(err); + return; + } + + connectParams.saslOauthBearer = saslOauthBearer; + + if (saslOauthBearer.username && !connectParams.nick) { + connectParams.nick = saslOauthBearer.username; + } + } + if (autojoin.length > 0) { if (connectParams.autoconnect) { // Ask the user whether they want to join that new channel. @@ -347,6 +386,59 @@ export default class App extends Component { } } + async redirectOauth2Authorize() { + let serverMetadata; + try { + serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url); + } catch (err) { + console.error("Failed to fetch OAuth 2.0 server metadata:", err); + this.showError("Failed to fetch OAuth 2.0 server metadata"); + } + + oauth2.redirectAuthorize({ + serverMetadata, + clientId: this.config.oauth2.client_id, + redirectUri: window.location.toString(), + scope: this.config.oauth2.scope, + }); + } + + async exchangeOauth2Code(code) { + let serverMetadata = await oauth2.fetchServerMetadata(this.config.oauth2.url); + + let redirectUri = new URL(window.location.toString()); + redirectUri.searchParams.delete("code"); + redirectUri.searchParams.delete("state"); + + let data = await oauth2.exchangeCode({ + serverMetadata, + redirectUri: redirectUri.toString(), + code, + clientId: this.config.oauth2.client_id, + clientSecret: this.config.oauth2.client_secret, + }); + + // TODO: handle expires_in/refresh_token + let token = data.access_token; + + let username = null; + if (serverMetadata.introspection_endpoint) { + try { + let data = await oauth2.introspectToken({ + serverMetadata, + token, + clientId: this.config.oauth2.client_id, + clientSecret: this.config.oauth2.client_secret, + }); + username = data.username; + } catch (err) { + console.warn("Failed to introspect OAuth 2.0 token:", err); + } + } + + return { token, username }; + } + showError(err) { console.error("App error: ", err); diff --git a/components/connect-form.js b/components/connect-form.js index 3158daa..9283b3d 100644 --- a/components/connect-form.js +++ b/components/connect-form.js @@ -63,6 +63,8 @@ export default class ConnectForm extends Component { }; } else if (this.props.auth === "external") { params.saslExternal = true; + } else if (this.props.auth === "oauth2") { + params.saslOauthBearer = this.props.params.saslOauthBearer; } if (this.state.autojoin) { @@ -110,7 +112,7 @@ export default class ConnectForm extends Component { } let auth = null; - if (this.props.auth !== "disabled" && this.props.auth !== "external") { + if (this.props.auth !== "disabled" && this.props.auth !== "external" && this.props.auth !== "oauth2") { auth = html`