diff --git a/server/graphql/v2/mutation/OAuthAuthorizationMutations.js b/server/graphql/v2/mutation/OAuthAuthorizationMutations.js index cd7d434c133..7088d0d7ac4 100644 --- a/server/graphql/v2/mutation/OAuthAuthorizationMutations.js +++ b/server/graphql/v2/mutation/OAuthAuthorizationMutations.js @@ -31,7 +31,7 @@ const oAuthAuthorizationMutations = { return { id: userToken.id, account: req.remoteUser.collective, - application: userToken.client, + application: userToken.application, expiresAt: userToken.accessTokenExpiresAt, createdAt: userToken.createdAt, updatedAt: userToken.updatedAt, diff --git a/server/graphql/v2/object/Application.js b/server/graphql/v2/object/Application.js index 6e572bc46f5..90e6f6af5c7 100644 --- a/server/graphql/v2/object/Application.js +++ b/server/graphql/v2/object/Application.js @@ -93,7 +93,7 @@ export const GraphQLApplication = new GraphQLObjectType({ return { id: userToken.id, account: req.remoteUser.collective, - application: userToken.client, + application: userToken.application, expiresAt: userToken.accessTokenExpiresAt, createdAt: userToken.createdAt, updatedAt: userToken.updatedAt, diff --git a/server/graphql/v2/object/Individual.ts b/server/graphql/v2/object/Individual.ts index 11a75f8bb6b..2f200f45217 100644 --- a/server/graphql/v2/object/Individual.ts +++ b/server/graphql/v2/object/Individual.ts @@ -191,7 +191,7 @@ export const GraphQLIndividual = new GraphQLObjectType({ return { id: row.id, account: collective, - application: row.client, + application: row.application, expiresAt: row.accessTokenExpiresAt, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/server/lib/oauth/index.ts b/server/lib/oauth/index.ts index 3835d74f8ba..97f9688573a 100644 --- a/server/lib/oauth/index.ts +++ b/server/lib/oauth/index.ts @@ -30,7 +30,13 @@ class CustomTokenHandler extends TokenHandler { auth.TOKEN_EXPIRATION_SESSION_OAUTH, // 90 days, ); - return new BearerTokenType(accessToken, auth.TOKEN_EXPIRATION_SESSION_OAUTH, null, model.scope.join(' ')); + // Include refresh token in the response so clients can refresh access tokens + return new BearerTokenType( + accessToken, + auth.TOKEN_EXPIRATION_SESSION_OAUTH, + model.refreshToken || null, + model.scope.join(' '), + ); }; } diff --git a/server/lib/oauth/model.ts b/server/lib/oauth/model.ts index 657888f6ff2..b4a4faaf191 100644 --- a/server/lib/oauth/model.ts +++ b/server/lib/oauth/model.ts @@ -33,7 +33,8 @@ interface OauthModel extends AuthorizationCodeModel, RefreshTokenModel {} export const dbApplicationToClient = (application: Application): OAuth2Server.Client => ({ id: application.clientId, redirectUris: [application.callbackUrl], - grants: ['authorization_code'], + // Allow exchanging authorization codes and refreshing access tokens + grants: ['authorization_code', 'refresh_token'], }); export const dbOAuthAuthorizationCodeToAuthorizationCode = ( @@ -49,6 +50,20 @@ export const dbOAuthAuthorizationCodeToAuthorizationCode = ( scope: authorization.scope, }); +const dbTokenToOAuthToken = async (token: UserToken): Promise => { + if (!token.user && token.UserId) { + token.user = await models.User.findOne({ where: { id: token.UserId } }); + } + if (!token.application && token.ApplicationId) { + token.application = await models.Application.findOne({ where: { id: token.ApplicationId } }); + } + if (!token.client && token.application) { + token.client = dbApplicationToClient(token.application); + } + + return token as Token; +}; + // For some reason `saveAuthorizationCode` and `saveToken` can receive a `scope` // property that is a string[], string or undefined, and in the case of a string // it may still be URL encoded. I wouldn't expected the framework to parse the @@ -109,9 +124,7 @@ const model: OauthModel = { scope, preAuthorize2FA: Boolean(application.preAuthorize2FA), }); - oauthToken.user = user; - oauthToken.client = client; - return oauthToken; + return (await dbTokenToOAuthToken(oauthToken)) as Token; } catch (e) { debug(e); // TODO: what should be thrown so it's properly catched on the library side? @@ -138,7 +151,7 @@ const model: OauthModel = { throw new InvalidTokenError('Invalid token'); } - return token; + return (await dbTokenToOAuthToken(token)) as Token; }, async getRefreshToken(refreshToken): Promise { @@ -148,7 +161,7 @@ const model: OauthModel = { throw new InvalidTokenError('Invalid refresh token'); } - return token; + return (await dbTokenToOAuthToken(token)) as RefreshToken; }, // -- Authorization code -- diff --git a/server/models/Application.ts b/server/models/Application.ts index d34f14ec696..85738c5895f 100644 --- a/server/models/Application.ts +++ b/server/models/Application.ts @@ -34,7 +34,7 @@ class Application extends Model, InferCreationAttri declare public createdAt: CreationOptional; declare public updatedAt: CreationOptional; declare public deletedAt: CreationOptional; - declare public data: JSON; + declare public data: Record; declare public createdByUser: NonAttribute; declare getCreatedByUser: BelongsToGetAssociationMixin; diff --git a/server/models/UserToken.ts b/server/models/UserToken.ts index bd4de7e951a..830687e0ce2 100644 --- a/server/models/UserToken.ts +++ b/server/models/UserToken.ts @@ -4,6 +4,7 @@ import type { CreationOptional, ForeignKey, InferAttributes, InferCreationAttrib import oAuthScopes from '../constants/oauth-scopes'; import sequelize, { DataTypes, Model } from '../lib/sequelize'; +import Application from './Application'; import User from './User'; export enum TokenType { @@ -28,6 +29,8 @@ class UserToken extends Model, InferCreationAttribute declare public lastUsedAt: CreationOptional; declare public user?: NonAttribute; + declare public application?: NonAttribute; + declare public client?: NonAttribute; hasScope(scope): boolean { @@ -113,7 +116,7 @@ UserToken.init( defaultScope: { include: [ { association: 'user', required: true }, - { association: 'client', required: true }, + { association: 'application', required: true }, ], }, }, diff --git a/server/models/index.ts b/server/models/index.ts index 8bbea9dafe6..654de270aee 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -331,7 +331,7 @@ User.hasMany(UserToken, { foreignKey: 'UserId' }); User.hasMany(UserTwoFactorMethod); // UserToken -UserToken.belongsTo(Application, { foreignKey: 'ApplicationId', as: 'client' }); +UserToken.belongsTo(Application, { foreignKey: 'ApplicationId', as: 'application' }); UserToken.belongsTo(User, { foreignKey: 'UserId', as: 'user' }); // UserTwoFactorMethod diff --git a/server/routes.ts b/server/routes.ts index b34ddcce519..4108ba0b88b 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -173,7 +173,7 @@ export default async (app: express.Application) => { // 2) GraphQL v1 is not officially supported and should not be used by third party developers if (req.userToken && req.userToken.type === 'OAUTH') { // We need exceptions for prototype and internal tools - if (!req.userToken.client?.data?.enableGraphqlV1) { + if (!req.userToken.application?.data?.enableGraphqlV1) { const errorMessage = 'OAuth access tokens are not accepted on GraphQL v1'; logger.warn(errorMessage); return next(new errors.Unauthorized(errorMessage)); diff --git a/test/server/lib/oauth/model.test.ts b/test/server/lib/oauth/model.test.ts index ce868c9bdb1..69a6b49a641 100644 --- a/test/server/lib/oauth/model.test.ts +++ b/test/server/lib/oauth/model.test.ts @@ -68,8 +68,8 @@ describe('server/lib/oauth/model', () => { expect(token.id).to.eq(userToken.id); expect(token.user).to.not.be.null; expect(token.user.id).to.eq(userToken.user.id); - expect(token.client).to.not.be.null; - expect(token.client.id).to.eq(userToken.client.id); + expect(token.application).to.not.be.null; + expect(token.application.id).to.eq(userToken.application.id); }); it('throws if the token does not exist', async () => { @@ -101,8 +101,8 @@ describe('server/lib/oauth/model', () => { expect(token.id).to.eq(userToken.id); expect(token.user).to.not.be.null; expect(token.user.id).to.eq(userToken.user.id); - expect(token.client).to.not.be.null; - expect(token.client.id).to.eq(userToken.client.id); + expect(token.application).to.not.be.null; + expect(token.application.id).to.eq(userToken.application.id); }); it('throws if the token does not exist', async () => {