mirror of
https://codeberg.org/emersion/gamja
synced 2025-03-12 23:43:42 +01:00
Add support for OAuth 2.0 authentication
This commit is contained in:
parent
bbc94c88c0
commit
e815295503
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",
|
"autojoin": "#gamja",
|
||||||
// Controls how the password UI is presented to the user. Set to
|
// Controls how the password UI is presented to the user. Set to
|
||||||
// "mandatory" to require a password, "optional" to accept one but not
|
// "mandatory" to require a password, "optional" to accept one but not
|
||||||
// require it, "disabled" to never ask for a password, or "external" to
|
// require it, "disabled" to never ask for a password, "external" to
|
||||||
// use SASL EXTERNAL. Defaults to "optional".
|
// use SASL EXTERNAL, "oauth2" to use SASL OAUTHBEARER. Defaults to
|
||||||
|
// "optional".
|
||||||
"auth": "optional",
|
"auth": "optional",
|
||||||
// Default nickname (string). If it contains a "*" character, it will
|
// Default nickname (string). If it contains a "*" character, it will
|
||||||
// be replaced with a random string.
|
// 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
|
// disable. Enabling PINGs can have an impact on client power usage and
|
||||||
// should only be enabled if necessary.
|
// should only be enabled if necessary.
|
||||||
"ping": 60
|
"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 * as irc from "../lib/irc.js";
|
||||||
import Client from "../lib/client.js";
|
import Client from "../lib/client.js";
|
||||||
|
import * as oauth2 from "../lib/oauth2.js";
|
||||||
import Buffer from "./buffer.js";
|
import Buffer from "./buffer.js";
|
||||||
import BufferList from "./buffer-list.js";
|
import BufferList from "./buffer-list.js";
|
||||||
import BufferHeader from "./buffer-header.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
|
* - Default server URL constructed from the current URL location (this is
|
||||||
* done in fillConnectParams)
|
* done in fillConnectParams)
|
||||||
*/
|
*/
|
||||||
handleConfig(config) {
|
async handleConfig(config) {
|
||||||
let connectParams = { ...this.state.connectParams };
|
let connectParams = { ...this.state.connectParams };
|
||||||
|
|
||||||
if (typeof config.server.url === "string") {
|
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\"");
|
console.error("Error in config.json: cannot set server.autoconnect = true and server.auth = \"mandatory\"");
|
||||||
connectParams.autoconnect = false;
|
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();
|
let autoconnect = store.autoconnect.load();
|
||||||
if (autoconnect) {
|
if (autoconnect) {
|
||||||
@ -329,6 +334,40 @@ export default class App extends Component {
|
|||||||
connectParams.nick = connectParams.nick.replace("*", placeholder);
|
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 (autojoin.length > 0) {
|
||||||
if (connectParams.autoconnect) {
|
if (connectParams.autoconnect) {
|
||||||
// Ask the user whether they want to join that new channel.
|
// 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) {
|
showError(err) {
|
||||||
console.error("App error: ", err);
|
console.error("App error: ", err);
|
||||||
|
|
||||||
|
@ -63,6 +63,8 @@ export default class ConnectForm extends Component {
|
|||||||
};
|
};
|
||||||
} else if (this.props.auth === "external") {
|
} else if (this.props.auth === "external") {
|
||||||
params.saslExternal = true;
|
params.saslExternal = true;
|
||||||
|
} else if (this.props.auth === "oauth2") {
|
||||||
|
params.saslOauthBearer = this.props.params.saslOauthBearer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.autojoin) {
|
if (this.state.autojoin) {
|
||||||
@ -110,7 +112,7 @@ export default class ConnectForm extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let auth = null;
|
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`
|
auth = html`
|
||||||
<label>
|
<label>
|
||||||
Password:<br/>
|
Password:<br/>
|
||||||
|
@ -122,6 +122,7 @@ export default class Client extends EventTarget {
|
|||||||
pass: null,
|
pass: null,
|
||||||
saslPlain: null,
|
saslPlain: null,
|
||||||
saslExternal: false,
|
saslExternal: false,
|
||||||
|
saslOauthBearer: null,
|
||||||
bouncerNetwork: null,
|
bouncerNetwork: null,
|
||||||
ping: 0,
|
ping: 0,
|
||||||
eventPlayback: true,
|
eventPlayback: true,
|
||||||
@ -467,6 +468,10 @@ export default class Client extends EventTarget {
|
|||||||
case "EXTERNAL":
|
case "EXTERNAL":
|
||||||
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
initialResp = { command: "AUTHENTICATE", params: [base64.encode("")] };
|
||||||
break;
|
break;
|
||||||
|
case "OAUTHBEARER":
|
||||||
|
let raw = "n,,\x01auth=Bearer " + params.token + "\x01\x01";
|
||||||
|
initialResp = { command: "AUTHENTICATE", params: [base64.encode(raw)] };
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
throw new Error(`Unknown authentication mechanism '${mechanism}'`);
|
||||||
}
|
}
|
||||||
@ -658,6 +663,8 @@ export default class Client extends EventTarget {
|
|||||||
promise = this.authenticate("PLAIN", this.params.saslPlain);
|
promise = this.authenticate("PLAIN", this.params.saslPlain);
|
||||||
} else if (this.params.saslExternal) {
|
} else if (this.params.saslExternal) {
|
||||||
promise = this.authenticate("EXTERNAL");
|
promise = this.authenticate("EXTERNAL");
|
||||||
|
} else if (this.params.saslOauthBearer) {
|
||||||
|
promise = this.authenticate("OAUTHBEARER", this.params.saslOauthBearer);
|
||||||
}
|
}
|
||||||
(promise || Promise.resolve()).catch((err) => {
|
(promise || Promise.resolve()).catch((err) => {
|
||||||
this.dispatchError(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…
x
Reference in New Issue
Block a user