mirror of
https://github.com/outline/outline.git
synced 2026-01-06 02:59:54 -06:00
OAuth provider (#8884)
This PR contains the necessary work to make Outline an OAuth provider including: - OAuth app registration - OAuth app management - Private / public apps (Public in cloud only) - Full OAuth 2.0 spec compatible authentication flow - Granular scopes - User token management screen in settings - Associated API endpoints for programatic access
This commit is contained in:
@@ -17,3 +17,13 @@ export function safeEqual(a?: string, b?: string) {
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string using SHA-256.
|
||||
*
|
||||
* @param input The input string to hash
|
||||
* @returns The hashed input
|
||||
*/
|
||||
export function hash(input: string) {
|
||||
return crypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
104
server/utils/oauth/OAuthInterface.test.ts
Normal file
104
server/utils/oauth/OAuthInterface.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { v4 } from "uuid";
|
||||
import { Scope } from "@shared/types";
|
||||
import { OAuthInterface } from "./OAuthInterface";
|
||||
|
||||
describe("OAuthInterface", () => {
|
||||
const user = {
|
||||
id: v4(),
|
||||
};
|
||||
const client = {
|
||||
id: v4(),
|
||||
grants: ["authorization_code", "refresh_token"],
|
||||
redirectUris: ["https://example.com/callback"],
|
||||
};
|
||||
|
||||
describe("#validateRedirectUri", () => {
|
||||
it("should return true for valid redirect URI", async () => {
|
||||
const redirectUri = "https://example.com/callback";
|
||||
const result = await OAuthInterface.validateRedirectUri(
|
||||
redirectUri,
|
||||
client
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("should return false for invalid redirect URI", async () => {
|
||||
const redirectUri = "invalid_uri";
|
||||
const result = await OAuthInterface.validateRedirectUri(
|
||||
redirectUri,
|
||||
client
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for URI with fragment", async () => {
|
||||
const redirectUri = "https://example.com/callback#fragment";
|
||||
const result = await OAuthInterface.validateRedirectUri(
|
||||
redirectUri,
|
||||
client
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#validateScope", () => {
|
||||
it("should return empty array for empty scope", async () => {
|
||||
const result = await OAuthInterface.validateScope(user, client, []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty scope", async () => {
|
||||
const result = await OAuthInterface.validateScope(
|
||||
user,
|
||||
client,
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should allow valid global scopes", async () => {
|
||||
const scope = [Scope.Read, Scope.Write];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toEqual(scope);
|
||||
});
|
||||
|
||||
it("should allow route scopes", async () => {
|
||||
const scope = [
|
||||
"/api/documents.info",
|
||||
"/api/documents.create",
|
||||
"/api/documents.update",
|
||||
"/api/documents.delete",
|
||||
];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toEqual(scope);
|
||||
});
|
||||
|
||||
it("should allow scopes with colon and valid prefix", async () => {
|
||||
const scope = [
|
||||
"documents:read",
|
||||
"documents:write",
|
||||
"collections:read",
|
||||
"collections:write",
|
||||
];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toEqual(scope);
|
||||
});
|
||||
|
||||
it("should reject invalid route scopes", async () => {
|
||||
const scope = ["invalid.scope.periods"];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid access scopes", async () => {
|
||||
const scope = ["documents:invalid"];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject malformed access scopes", async () => {
|
||||
const scope = ["documents::read"];
|
||||
const result = await OAuthInterface.validateScope(user, client, scope);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
294
server/utils/oauth/OAuthInterface.ts
Normal file
294
server/utils/oauth/OAuthInterface.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
RefreshTokenModel,
|
||||
AuthorizationCodeModel,
|
||||
} from "@node-oauth/oauth2-server";
|
||||
import { Required } from "utility-types";
|
||||
import { Scope } from "@shared/types";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import {
|
||||
OAuthClient,
|
||||
OAuthAuthentication,
|
||||
OAuthAuthorizationCode,
|
||||
} from "@server/models";
|
||||
import { hash, safeEqual } from "@server/utils/crypto";
|
||||
|
||||
/**
|
||||
* Additional configuration for the OAuthInterface, not part of the
|
||||
* OAuth2Server library.
|
||||
*/
|
||||
interface Config {
|
||||
grants: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This interface is used by the OAuth2Server library to handle OAuth2
|
||||
* authentication and authorization flows. See the library's documentation:
|
||||
*
|
||||
* https://node-oauthoauth2-server.readthedocs.io/en/master/model/overview.html
|
||||
*/
|
||||
export const OAuthInterface: RefreshTokenModel &
|
||||
Required<
|
||||
AuthorizationCodeModel,
|
||||
| "validateScope"
|
||||
| "validateRedirectUri"
|
||||
| "generateAccessToken"
|
||||
| "generateRefreshToken"
|
||||
| "generateAuthorizationCode"
|
||||
> &
|
||||
Config = {
|
||||
/** Supported grant types */
|
||||
grants: ["authorization_code", "refresh_token"],
|
||||
|
||||
async generateAccessToken() {
|
||||
return `${OAuthAuthentication.accessTokenPrefix}${crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex")}`;
|
||||
},
|
||||
|
||||
async generateRefreshToken() {
|
||||
return `${OAuthAuthentication.refreshTokenPrefix}${crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex")}`;
|
||||
},
|
||||
|
||||
async generateAuthorizationCode() {
|
||||
return `${OAuthAuthorizationCode.authorizationCodePrefix}${crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex")}`;
|
||||
},
|
||||
|
||||
async getAccessToken(accessToken: string) {
|
||||
const authentication = await OAuthAuthentication.findByAccessToken(
|
||||
accessToken
|
||||
);
|
||||
if (!authentication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: authentication.accessTokenExpiresAt,
|
||||
scope: authentication.scope,
|
||||
client: {
|
||||
id: authentication.oauthClientId,
|
||||
grants: this.grants,
|
||||
},
|
||||
user: authentication.user,
|
||||
};
|
||||
},
|
||||
|
||||
async getRefreshToken(refreshToken: string) {
|
||||
const authentication = await OAuthAuthentication.findByRefreshToken(
|
||||
refreshToken
|
||||
);
|
||||
if (!authentication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: authentication.refreshTokenExpiresAt,
|
||||
scope: authentication.scope,
|
||||
client: {
|
||||
id: authentication.oauthClientId,
|
||||
grants: this.grants,
|
||||
},
|
||||
user: authentication.user,
|
||||
};
|
||||
},
|
||||
|
||||
async getAuthorizationCode(authorizationCode) {
|
||||
const code = await OAuthAuthorizationCode.findByCode(authorizationCode);
|
||||
if (!code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oauthClient = await OAuthClient.findByPk(code.oauthClientId);
|
||||
if (!oauthClient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
authorizationCode,
|
||||
expiresAt: code.expiresAt,
|
||||
scope: code.scope,
|
||||
redirectUri: code.redirectUri,
|
||||
codeChallenge: code.codeChallenge,
|
||||
codeChallengeMethod: code.codeChallengeMethod,
|
||||
client: {
|
||||
id: oauthClient.clientId,
|
||||
grants: this.grants,
|
||||
},
|
||||
user: code.user,
|
||||
};
|
||||
},
|
||||
|
||||
async getClient(clientId: string, clientSecret?: string) {
|
||||
const client = await OAuthClient.findByClientId(clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clientSecret && !safeEqual(client.clientSecret, clientSecret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
id: client.clientId,
|
||||
redirectUris: client.redirectUris,
|
||||
databaseId: client.id,
|
||||
grants: this.grants,
|
||||
};
|
||||
},
|
||||
|
||||
async saveToken(token, client, user) {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
} = token;
|
||||
const accessTokenHash = hash(accessToken);
|
||||
const refreshTokenHash = refreshToken ? hash(refreshToken) : undefined;
|
||||
|
||||
await OAuthAuthentication.create({
|
||||
accessTokenHash,
|
||||
refreshTokenHash,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
scope: token.scope,
|
||||
oauthClientId: client.databaseId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
scope: token.scope,
|
||||
client: {
|
||||
id: client.id,
|
||||
grants: this.grants,
|
||||
},
|
||||
user,
|
||||
};
|
||||
},
|
||||
|
||||
async saveAuthorizationCode(code, client, user) {
|
||||
const {
|
||||
authorizationCode,
|
||||
expiresAt,
|
||||
redirectUri,
|
||||
scope,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
} = code;
|
||||
|
||||
const authCode = await OAuthAuthorizationCode.create({
|
||||
authorizationCodeHash: hash(authorizationCode),
|
||||
expiresAt,
|
||||
scope,
|
||||
redirectUri,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
oauthClientId: client.databaseId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
authorizationCode,
|
||||
expiresAt,
|
||||
scope,
|
||||
redirectUri,
|
||||
client: {
|
||||
id: client.id,
|
||||
grants: this.grants,
|
||||
},
|
||||
user: authCode.user,
|
||||
};
|
||||
},
|
||||
|
||||
async revokeToken(token) {
|
||||
const auth = await OAuthAuthentication.findByRefreshToken(
|
||||
token.refreshToken
|
||||
);
|
||||
if (auth) {
|
||||
await auth.destroy();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async revokeAuthorizationCode(code) {
|
||||
const authCode = await OAuthAuthorizationCode.findByCode(
|
||||
code.authorizationCode
|
||||
);
|
||||
if (authCode) {
|
||||
await authCode.destroy();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure the redirect URI is not plain HTTP. Custom protocols are allowed.
|
||||
*
|
||||
* @param uri The redirect URI to validate.
|
||||
* @returns True if the URI is valid, false otherwise.
|
||||
*/
|
||||
async validateRedirectUri(uri, client) {
|
||||
if (uri.includes("#") || uri.includes("*")) {
|
||||
return false;
|
||||
}
|
||||
if (!client.redirectUris?.includes(uri)) {
|
||||
return false;
|
||||
}
|
||||
if (!isUrl(uri, { requireHttps: true })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Invoked to check if the requested scope is valid for a particular
|
||||
* client/user combination.
|
||||
*
|
||||
* @param scope The requested scopes.
|
||||
* @returns The scopes if valid, false otherwise.
|
||||
*/
|
||||
async validateScope(user, client, scope) {
|
||||
if (!scope?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopes = Array.isArray(scope) ? scope : [scope];
|
||||
const validAccessScopes = Object.values(Scope);
|
||||
|
||||
return scopes.some((s: string) => {
|
||||
if (validAccessScopes.includes(s as Scope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const periodCount = (s.match(/\./g) || []).length;
|
||||
const colonCount = (s.match(/:/g) || []).length;
|
||||
|
||||
if (periodCount === 1 && colonCount === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
colonCount === 1 &&
|
||||
validAccessScopes.includes(s.split(":")[1] as Scope)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
? scopes
|
||||
: false;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user