mirror of
https://git.sr.ht/~emersion/gamja
synced 2025-02-26 16:53:51 -05:00
Add support for OAuth 2.0 authentication
This commit is contained in:
parent
bbc94c88c0
commit
e815295503
5 changed files with 228 additions and 4 deletions
18
README.md
18
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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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`
|
||||
<label>
|
||||
Password:<br/>
|
||||
|
|
|
@ -122,6 +122,7 @@ export default class Client extends EventTarget {
|
|||
pass: null,
|
||||
saslPlain: null,
|
||||
saslExternal: false,
|
||||
saslOauthBearer: null,
|
||||
bouncerNetwork: null,
|
||||
ping: 0,
|
||||
eventPlayback: true,
|
||||
|
@ -467,6 +468,10 @@ export default class Client extends EventTarget {
|
|||
case "EXTERNAL":
|
||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
||||
break;
|
||||
case "OAUTHBEARER":
|
||||
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
||||
}
|
||||
|
@ -658,6 +663,8 @@ export default class Client extends EventTarget {
|
|||
promise = this.authenticate("PLAIN", this.params.saslPlain);
|
||||
} else if (this.params.saslExternal) {
|
||||
promise = this.authenticate("EXTERNAL");
|
||||
} else if (this.params.saslOauthBearer) {
|
||||
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
|
||||
}
|
||||
(promise || Promise.resolve()).catch((err) => {
|
||||
this.dispatchError(err);
|
||||
|
|
109
lib/oauth2.js
Normal file
109
lib/oauth2.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
function formatQueryString(params) {
|
||||
let l = [];
|
||||
for (let k in params) {
|
||||
l.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
|
||||
}
|
||||
return l.join("&");
|
||||
}
|
||||
|
||||
export async function fetchServerMetadata(url) {
|
||||
// TODO: handle path in config.oauth2.url
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url + "/.well-known/oauth-authorization-server");
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("OAuth 2.0 server doesn't support Authorization Server Metadata (retrying with OpenID Connect Discovery): ", err);
|
||||
resp = await fetch(url + "/.well-known/openid-configuration");
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
let data = await resp.json();
|
||||
if (!data.issuer) {
|
||||
throw new Error("Missing issuer in response");
|
||||
}
|
||||
if (!data.authorization_endpoint) {
|
||||
throw new Error("Missing authorization_endpoint in response");
|
||||
}
|
||||
if (!data.token_endpoint) {
|
||||
throw new Error("Missing authorization_endpoint in response");
|
||||
}
|
||||
if (!data.response_types_supported.includes("code")) {
|
||||
throw new Error("Server doesn't support authorization code response type");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function redirectAuthorize({ serverMetadata, clientId, redirectUri, scope }) {
|
||||
// TODO: move fragment to query string in redirect_uri
|
||||
// TODO: use the state param to prevent cross-site request
|
||||
// forgery
|
||||
let params = {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
if (scope) {
|
||||
params.scope = scope;
|
||||
}
|
||||
window.location.assign(serverMetadata.authorization_endpoint + "?" + formatQueryString(params));
|
||||
}
|
||||
|
||||
function buildPostHeaders(clientId, clientSecret) {
|
||||
let headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
};
|
||||
if (clientSecret) {
|
||||
headers["Authorization"] = "Basic " + btoa(encodeURIComponent(clientId) + ":" + encodeURIComponent(clientSecret));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function exchangeCode({ serverMetadata, redirectUri, code, clientId, clientSecret }) {
|
||||
let data = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
if (!clientSecret) {
|
||||
data.client_id = clientId;
|
||||
}
|
||||
|
||||
let resp = await fetch(serverMetadata.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: buildPostHeaders(clientId, clientSecret),
|
||||
body: formatQueryString(data),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error("Authentication failed: " + (data.error_description || data.error));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function introspectToken({ serverMetadata, token, clientId, clientSecret }) {
|
||||
let resp = await fetch(serverMetadata.introspection_endpoint, {
|
||||
method: "POST",
|
||||
headers: buildPostHeaders(clientId, clientSecret),
|
||||
body: formatQueryString({ token }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP error: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
let data = await resp.json();
|
||||
if (!data.active) {
|
||||
throw new Error("Expired token");
|
||||
}
|
||||
return data;
|
||||
}
|
Loading…
Reference in a new issue